Skip to content

WIP: Add MJML support.#2643

Open
knadh wants to merge 2 commits into
masterfrom
mjml
Open

WIP: Add MJML support.#2643
knadh wants to merge 2 commits into
masterfrom
mjml

Conversation

@knadh

@knadh knadh commented Aug 31, 2025

Copy link
Copy Markdown
Owner

No description provided.

@Rush

Rush commented Sep 30, 2025

Copy link
Copy Markdown

Nice, it'd be pretty great to have! What's remaining to merge it?

@knadh

knadh commented Sep 30, 2025

Copy link
Copy Markdown
Owner Author

The MJML lib version that I tested this branch was broken, but it's improved since then. That dependency has to be updated and the implementation has to be tested fully. If you're able to do it, that would be great!

@knadh

knadh commented Mar 6, 2026

Copy link
Copy Markdown
Owner Author

I've updated the mjml branch with the latest version of the mjml lib. Test Linux build is here: listmonk.tar.gz

Someone has to test this out and help before I can merge it :)

@preslavrachev

preslavrachev commented Apr 2, 2026

Copy link
Copy Markdown

@knadh feel free to bump up to v0.11.0 : https://github.com/preslavrachev/gomjml/releases/tag/v0.11.0

@preslavrachev

Copy link
Copy Markdown

@knadh I forked the entire repo, and will issue a separate PR to merge my mjml changes into yours. There is a bit of a chicken-and-egg problem I am trying to hopefully solve soon. In short, listmonk, assumes that people create campaigns which may reuse the same template. The issue is that MJML compilers cannot compile and validate partial MJML code, you need to combine everything - the campaign, wrapped inside the template, before you pass it onto the MJML compiler. It's the opposite of what's currently happening in listmonk, and I am working on an adaptation strategy.

@knadh knadh force-pushed the mjml branch 3 times, most recently from b984dfd to 35d784c Compare April 28, 2026 17:58
@knadh

knadh commented Apr 28, 2026

Copy link
Copy Markdown
Owner Author

@preslavrachev I've addressed this and force pushed to the mjml branch. Please give it a shot.

  • The base template can now have <mjml> ... {{ template "content" . }} </mjml>
  • Or it can just have {{ template "content" . }} and expect the full mjml body in the campaign.

@dfabulich

Copy link
Copy Markdown
Contributor

I've pulled down the mjml branch and I have a bug to report. (I could file a PR on this, too, if you'd like.)

There's an error message if you select "MJML" on the first page of the Campaign wizard. "Error compiling campaign body: error compiling MJML: failed to parse MJML: EOF".

At that point, the campaign body is blank (of course). It should tolerate an empty body until I get to the second tab.

@knadh knadh added the hodor-review Automated AI code review label Jun 21, 2026
@knadh

knadh commented Jun 21, 2026

Copy link
Copy Markdown
Owner Author

@dfabulich amended the branch with a fix. Verified campaign creation and type conversion.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issues Found

Critical (P0/P1)

  • [P1] Apply template-func normalization to MJML body before mjml.Render (models/campaigns.go:171-188)
    • In CompileTemplate() the MJML path substitutes c.Body into the base and runs mjml.Render() before the campaign body ever goes through regTplFuncs rewriting (and the content sub-template is later set to ""). This means shorthand template helper calls inside the campaign body (eg {{ TrackLink "..." }}, {{ TrackView }} etc.) won't be rewritten to include dot context and can fail at render/send time even though non-MJML bodies get this normalization.
  • [P1] Fix content conversion paths involving MJML (returns non-MJML / can't handle partials) (models/campaigns.go:272-297)
    • ConvertContent() treats markdown -> mjml as markdown -> HTML (returning plain HTML but labeling it MJML), which will typically fail later when compiling/saving a MJML campaign because the result isn't a valid MJML document/fragment. Additionally mjml -> html|richtext renders only c.Body, so it will error for the common case where the campaign body is a partial MJML fragment intended to be wrapped by a selected base MJML template (the frontend format-switch uses this endpoint).

Summary

Total issues: 2 critical, 0 important, 0 minor.

Overall Verdict

Status: Patch has blocking issues

Explanation: The PR adds MJML support, but the MJML compilation path skips existing template-helper normalization for the campaign body, and the new conversion routes involving MJML produce invalid output or fail for common template-wrapped MJML workflows. These issues are likely to break rendering/sending or editor format switching for real campaigns.


Review generated by Hodor (model: gpt-5.2)

Review Metrics — 35 turns, 34 tool calls, 4m 59s

  • Tokens: in 20.9K | cached 537.7K | out 16.8K (total 575.4K)
  • Cost: $0.3652

@dfabulich

dfabulich commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

In #3131 I've fixed the first P1 issue Hodor raised.

Hodor's second P1 issue is quite confusing, but I don't think it's a real issue.

[P1] Fix content conversion paths involving MJML (returns non-MJML / can't handle partials) (models/campaigns.go:272-297)

  • ConvertContent() treats markdown -> mjml as markdown -> HTML (returning plain HTML but labeling it MJML), which will typically fail later when compiling/saving a MJML campaign because the result isn't a valid MJML document/fragment. Additionally mjml -> html|richtext renders only c.Body, so it will error for the common case where the campaign body is a partial MJML fragment intended to be wrapped by a selected base MJML template (the frontend format-switch uses this endpoint).

This is actually two issues:

  1. ConvertContent() treats markdown -> mjml as markdown -> HTML (returning plain HTML but labeling it MJML)
  2. mjml -> html|richtext renders only c.Body, so it will error for the common case where the campaign body is a partial MJML fragment intended to be wrapped by a selected base MJML template (the frontend format-switch uses this endpoint).

I think the second issue is simply incorrect; I tested partial MJML with the provided "Sample MJML template" and it worked just fine, setting the campaign MJML to <mj-text>hello world</mj-text>. The campaign sent, and, when converting from MJML to HTML or to Richtext, it left the <mj-text> element untransformed, which seems fine to me; HTML just treats unknown elements as <span>s. (The user is warned that the transformation is lossy.)

The gotcha is that embedding HTML or partial MJML inside an MJML template requires you to know/manage the current context element. The provided "Sample MJML template" embeds the email content in an <mj-column> inside an <mj-section> inside <mj-body>, like this:

<mjml>
  <mj-body background-color="#F0F1F3">
    <!-- Main Content Wrapper -->
    <mj-section background-color="#fff" border-radius="5px" padding="30px">
      <mj-column>
        {{ template "content" . }}
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>

If your partial MJML is just text like Hello world, that won't render properly. You're not allowed to just drop text in an <mj-column>; you need to put your text in an <mj-text> element.

But Listmonk has no way of knowing how users will construct their templates. If, instead, the template had included an <mj-text> element, like this, Hello world would render fine:

<mjml>
  <mj-body background-color="#F0F1F3">
    <!-- Main Content Wrapper -->
    <mj-section background-color="#fff" border-radius="5px" padding="30px">
      <mj-column>
        <mj-text>
          {{ template "content" . }}
        </mj-text>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>

Arbitrary HTML renders fine inside an <mj-text>. So, if the template provided its own <mj-text> element, typing stuff in the Rich Text editor or the Markdown editor and "Converting to MJML" by just rendering it as HTML would work fine; the HTML -> MJML "converter" would be a no-op.

But a template like that would also be very confining. If you wanted to add more sections/columns to your MJML campaign, you'd first have to close out the tags that the template provided, and then reopen tags for the template to close, like this:

</mj-text>
</mj-column>
</mj-section>

<!-- now include your custom section -->
<mj-section>
  <mj-column>
    <mj-text>hello</mj-text>
  </mj-column>
</mj-section>

<!-- now you have to reopen your tags again so the template can close them -->
<mj-section>
<mj-column>
<mj-text>

Overall, it's not at all clear to me what a Listmonk "Sample MJML Template" should provide, but I suspect that it should either provide its own <mj-text>, which means converting from HTML to MJML would be a no-op, or it should only provide an <mj-body>, with an <mj-head> that sets <mj-attributes> to provide default attributes for <mj-section>s.

If the template only provides an <mj-body>, then, when converting from Markdown / HTML to MJML, somebody would need to wrap the resulting MJML in <mj-section><mj-column><mj-text> tags so it will render. That could be a manual step for campaign authors, or it could be in code, so the Markdown/HTML -> MJML converter could automatically add those tags.

Regardless, I don't think this is a P1 issue. With my PR #3131 merged, I think MJML support is ready to ship.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issues Found

Critical (P0/P1)

  • [P1] Validate subject/headers templates again in validateCampaignFields (cmd/campaigns.go:720-737)
    • validateCampaignFields() now compiles a freshly-constructed models.Campaign containing only Body/ContentType/TemplateBody, so template expressions in Subject, AltBody, and header values are no longer validated on create/update and will only fail later during send/preview. This is a regression from the previous c.CompileTemplate(...) call and can lead to campaigns being saved successfully but erroring at runtime when compiling templates.
  • [P1] Fix TrackLink shorthand causing invalid MJML before mjml.Render (models/campaigns.go:171-186)
    • MJML compilation runs regTplFuncs replacements and then calls mjml.Render() on the pre-template source; the @TrackLink rewrite produces {{ TrackLink "..." . }} which commonly sits inside an already-quoted attribute (e.g. href="https://x@TrackLink"href="{{ TrackLink "https://x" . }}"), yielding nested unescaped quotes that MJML/HTML parsers typically can't parse, so otherwise-valid MJML campaigns with tracked links can fail to compile.

Important (P2)

  • [P2] Avoid treating MJML as HTML during format conversion in Editor.vue (frontend/src/components/Editor.vue:160-232)
    • The editor now includes 'mjml' in the "HTML-like" branch (div.innerHTML + beautifyHTML), which can mutate MJML before sending it to the backend for MJML→HTML conversion; additionally the plain→mjml conversion path only inserts <br> tags (HTML fragment) while switching the type to MJML, which will typically produce invalid MJML and later compilation errors when saving/sending.

Summary

Total issues: 2 critical, 1 important, 0 minor.

Overall Verdict

Status: Patch has blocking issues

Explanation: The PR introduces MJML support but has regressions in campaign validation (subject/headers no longer template-validated) and likely MJML compilation failures for common tracked-link patterns due to quote rewriting prior to MJML parsing. The frontend also performs lossy/incorrect conversions into MJML that can lead to invalid saved content.


Review generated by Hodor (model: gpt-5.2)

Review Metrics — 19 turns, 23 tool calls, 6m 20s

  • Tokens: in 25.8K | cached 390.0K | out 18.7K (total 434.6K)
  • Cost: $0.3752

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

hodor-review Automated AI code review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants