From febf30c62b99f5f34a40af83a6cd3e6be06e1449 Mon Sep 17 00:00:00 2001 From: Kailash Nadh Date: Tue, 19 Aug 2025 00:09:19 +0530 Subject: [PATCH 1/2] Add MJML templating support to campaigns. --- cmd/campaigns.go | 24 ++++++++++++--- cmd/install.go | 9 ++++++ frontend/src/components/CodeEditor.vue | 3 ++ frontend/src/components/Editor.vue | 22 ++++++++++++-- frontend/src/views/Campaign.vue | 1 + go.mod | 1 + go.sum | 6 ++++ internal/migrations/v6.2.0.go | 19 ++++++++++++ models/campaigns.go | 37 +++++++++++++++++++---- models/common.go | 4 +++ schema.sql | 2 +- static/email-templates/sample-mjml.tpl | 41 ++++++++++++++++++++++++++ 12 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 static/email-templates/sample-mjml.tpl diff --git a/cmd/campaigns.go b/cmd/campaigns.go index 07d20ce30..2da596ef6 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -688,7 +688,8 @@ func (a *App) validateCampaignFields(c campReq) (campReq, error) { c.ContentType != models.CampaignContentTypeHTML && c.ContentType != models.CampaignContentTypePlain && c.ContentType != models.CampaignContentTypeVisual && - c.ContentType != models.CampaignContentTypeMarkdown { + c.ContentType != models.CampaignContentTypeMarkdown && + c.ContentType != models.CampaignContentTypeMJML { c.ContentType = models.CampaignContentTypeRichtext } @@ -716,9 +717,24 @@ func (a *App) validateCampaignFields(c campReq) (campReq, error) { } } - camp := models.Campaign{Body: c.Body, TemplateBody: tplTag} - if err := c.CompileTemplate(a.manager.TemplateFuncs(&camp)); err != nil { - return c, errors.New(a.i18n.Ts("campaigns.fieldInvalidBody", "error", err.Error())) + // Empty MJML drafts are valid while creating/editing, but gomjml rejects + // an empty document with EOF. Validate only when there's something to compile. + shouldCompile := c.ContentType != models.CampaignContentTypeMJML || strings.TrimSpace(c.Body) != "" + if shouldCompile { + templateBody := tplTag + + if c.ContentType == models.CampaignContentTypeMJML && c.TemplateID.Valid && c.TemplateID.Int > 0 { + tpl, err := a.core.GetTemplate(c.TemplateID.Int, false) + if err != nil { + return c, err + } + templateBody = tpl.Body + } + + camp := models.Campaign{Body: c.Body, ContentType: c.ContentType, TemplateBody: templateBody} + if err := camp.CompileTemplate(a.manager.TemplateFuncs(&camp)); err != nil { + return c, errors.New(a.i18n.Ts("campaigns.fieldInvalidBody", "error", err.Error())) + } } if len(c.Headers) == 0 { diff --git a/cmd/install.go b/cmd/install.go index f5449a1af..d99536f2a 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -237,6 +237,15 @@ func installTemplates(q *models.Queries) (int, int) { lo.Fatalf("error creating default campaign template: %v", err) } + // Insert MJML template. + tpl, err := fs.Get("/static/email-templates/sample-mjml.tpl") + if err != nil { + lo.Fatalf("error reading sample mjml template: %v", err) + } + if _, err := q.CreateTemplate.Exec("Sample MJML template", models.TemplateTypeCampaign, "", tpl.ReadBytes(), nil); err != nil { + lo.Fatalf("error creating mjml campaign template: %v", err) + } + return campTplID, archiveTplID } diff --git a/frontend/src/components/CodeEditor.vue b/frontend/src/components/CodeEditor.vue index 621e9b13f..ec8530910 100644 --- a/frontend/src/components/CodeEditor.vue +++ b/frontend/src/components/CodeEditor.vue @@ -50,6 +50,9 @@ export default { case 'html': langs = [html()]; break; + case 'mjml': + langs = [html()]; + break; case 'css': langs = [css()]; break; diff --git a/frontend/src/components/Editor.vue b/frontend/src/components/Editor.vue index d1205a0fb..4d278d8a5 100644 --- a/frontend/src/components/Editor.vue +++ b/frontend/src/components/Editor.vue @@ -67,6 +67,9 @@ + + + @@ -162,7 +165,7 @@ export default { // If `from` is HTML content, strip out `..` etc. and keep the beautified HTML. let isHTML = false; - if (from === 'richtext' || from === 'html' || from === 'visual') { + if (from === 'richtext' || from === 'html' || from === 'visual' || from === 'mjml') { const d = document.createElement('div'); d.innerHTML = body; body = this.beautifyHTML(d.innerHTML.trim()); @@ -198,7 +201,7 @@ export default { } // Markdown to HTML requires a backend call. - } else if (from === 'markdown' && (to === 'richtext' || to === 'html')) { + } else if (from === 'markdown' && (to === 'richtext' || to === 'html' || to === 'mjml')) { skip = true; this.$api.convertCampaignContent({ id: 1, body, from, to, @@ -212,8 +215,21 @@ export default { }); // Plain to an HTML type, change plain line breaks to HTML breaks. - } else if (from === 'plain' && (to === 'richtext' || to === 'html')) { + } else if (from === 'plain' && (to === 'richtext' || to === 'html' || to === 'mjml')) { body = body.replace(/\n/ig, '
\n'); + } else if (from === 'mjml' && (to === 'richtext' || to === 'html')) { + // MJML to HTML requires a backend call. + skip = true; + this.$api.convertCampaignContent({ + id: 1, body, from, to, + }).then((data) => { + this.$nextTick(() => { + // Both type + body should be updated in one cycle to avoid firing + // multiple events. + this.self.contentType = to; + this.self.body = this.beautifyHTML(data.trim()); + }); + }); } else if (to === 'visual') { bodySource = JSON.stringify(markdownToVisualBlock(body)); } diff --git a/frontend/src/views/Campaign.vue b/frontend/src/views/Campaign.vue index 93362b033..7b6c5f9af 100644 --- a/frontend/src/views/Campaign.vue +++ b/frontend/src/views/Campaign.vue @@ -349,6 +349,7 @@ export default Vue.extend({ markdown: this.$t('campaigns.markdown'), plain: this.$t('campaigns.plainText'), visual: this.$t('campaigns.visual'), + mjml: 'MJML', }), isNew: false, diff --git a/go.mod b/go.mod index 6fafdff50..ed7ef14fa 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/lib/pq v1.10.9 github.com/paulbellamy/ratecounter v0.2.0 github.com/pquerna/otp v1.5.0 + github.com/preslavrachev/gomjml v0.12.0 github.com/rhnvrm/simples3 v0.9.1 github.com/spf13/pflag v1.0.6 github.com/yuin/goldmark v1.7.12 diff --git a/go.sum b/go.sum index c4acc3976..ed1c7d8a5 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,12 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= +github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= github.com/altcha-org/altcha-lib-go v1.0.0 h1:7oPti0aUS+YCep8nwt5b9g4jYfCU55ZruWESL8G9K5M= github.com/altcha-org/altcha-lib-go v1.0.0/go.mod h1:I8ESLVWR9C58uvGufB/AJDPhaSU4+4Oh3DLpVtgwDAk= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= @@ -108,6 +112,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/preslavrachev/gomjml v0.12.0 h1:woWveeBIQ8OsU1PeCxZO5Y3KL6xJqyV6LdtPlFtn408= +github.com/preslavrachev/gomjml v0.12.0/go.mod h1:10tpMJhl+46mqf+5wG18fOXaWNB+OOllCpksDRJlJTU= github.com/rhnvrm/simples3 v0.9.1 h1:pYfEe2wTjx8B2zFzUdy4kZn3I3Otd9ZvzIhHkFR85kE= github.com/rhnvrm/simples3 v0.9.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= diff --git a/internal/migrations/v6.2.0.go b/internal/migrations/v6.2.0.go index 7c07a399c..ec0682df2 100644 --- a/internal/migrations/v6.2.0.go +++ b/internal/migrations/v6.2.0.go @@ -8,6 +8,7 @@ import ( "github.com/knadh/stuffbin" ) +// V6_2_0 performs the DB migrations for v6.2.0. func V6_2_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error { // Add `msg_retry_delay` to each SMTP server entry in the `smtp` settings JSON array. // Idempotent: only updates rows where at least one entry is missing the key. @@ -62,5 +63,23 @@ func V6_2_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger return err } + // Add MJML to content_type enum if not exists. + if _, err := db.Exec(`ALTER TYPE content_type ADD VALUE IF NOT EXISTS 'mjml';`); err != nil { + return err + } + + // Insert sample MJML template. + tpl, err := fs.Get("/static/email-templates/sample-mjml.tpl") + if err != nil { + return err + } + if _, err := db.Exec(` + INSERT INTO templates (name, type, subject, body) + SELECT $1, $2::template_type, $3, $4 + WHERE NOT EXISTS (SELECT 1 FROM templates WHERE name = $1 AND type = $2::template_type);`, + "Sample MJML template", "campaign", "", tpl.ReadBytes()); err != nil { + return err + } + return nil } diff --git a/models/campaigns.go b/models/campaigns.go index 43e9b6a81..8add87a40 100644 --- a/models/campaigns.go +++ b/models/campaigns.go @@ -12,6 +12,7 @@ import ( "github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx/types" "github.com/lib/pq" + "github.com/preslavrachev/gomjml/mjml" null "gopkg.in/volatiletech/null.v6" ) @@ -29,6 +30,7 @@ const ( CampaignContentTypeMarkdown = "markdown" CampaignContentTypePlain = "plain" CampaignContentTypeVisual = "visual" + CampaignContentTypeMJML = "mjml" ) // Campaigns represents a slice of Campaigns. @@ -156,8 +158,9 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error { // Compile the base template. body := c.TemplateBody + hasBaseTpl := body != "" - if body == "" || c.ContentType == CampaignContentTypeVisual { + if !hasBaseTpl || c.ContentType == CampaignContentTypeVisual { body = `{{ template "content" . }}` } @@ -165,19 +168,36 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error { body = r.regExp.ReplaceAllString(body, r.replace) } + // For an MJML campaign, the entire body is one MJML document. Substitute + // the campaign body for the `{{ template "content" . }}` placeholder in + // the base, then run mjml.Render once. + if c.ContentType == CampaignContentTypeMJML { + b := regexpTplTag.ReplaceAllLiteralString(body, c.Body) + htmlBody, err := mjml.Render(b) + if err != nil { + return fmt.Errorf("error compiling MJML: %v", err) + } + body = htmlBody + } + baseTPL, err := template.New(BaseTpl).Funcs(f).Parse(body) if err != nil { return fmt.Errorf("error compiling base template: %v", err) } - // If the format is markdown, convert Markdown to HTML. - if c.ContentType == CampaignContentTypeMarkdown { + // Pick the body to assign to the `content` sub-template. + switch c.ContentType { + case CampaignContentTypeMJML: + body = "" + + case CampaignContentTypeMarkdown: var b bytes.Buffer if err := markdown.Convert([]byte(c.Body), &b); err != nil { return err } body = b.String() - } else { + + default: body = c.Body } @@ -258,12 +278,19 @@ func (c *Campaign) ConvertContent(from, to string) (string, error) { // If the format is markdown, convert Markdown to HTML. var out string if from == CampaignContentTypeMarkdown && - (to == CampaignContentTypeHTML || to == CampaignContentTypeRichtext) { + (to == CampaignContentTypeHTML || to == CampaignContentTypeRichtext || to == CampaignContentTypeMJML) { var b bytes.Buffer if err := markdown.Convert([]byte(c.Body), &b); err != nil { return out, err } out = b.String() + } else if from == CampaignContentTypeMJML && + (to == CampaignContentTypeHTML || to == CampaignContentTypeRichtext) { + htmlBody, err := mjml.Render(c.Body) + if err != nil { + return out, fmt.Errorf("error converting MJML: %v", err) + } + out = htmlBody } else { return out, errors.New("unknown formats to convert") } diff --git a/models/common.go b/models/common.go index 7d060b525..9a6895aca 100644 --- a/models/common.go +++ b/models/common.go @@ -40,6 +40,10 @@ type regTplFunc struct { replace string } +// regexpTplTag matches a `{{ template "content" . }}` directive with any +// amount of internal whitespace. +var regexpTplTag = regexp.MustCompile(`{{\s*template\s+"content"\s+\.\s*}}`) + var regTplFuncs = []regTplFunc{ // Regular expression for matching {{ TrackLink "http://link.com" }} in the template // and substituting it with {{ TrackLink "http://link.com" . }} (the dot context) diff --git a/schema.sql b/schema.sql index 5b3c08d6f..e382849d5 100644 --- a/schema.sql +++ b/schema.sql @@ -5,7 +5,7 @@ DROP TYPE IF EXISTS subscriber_status CASCADE; CREATE TYPE subscriber_status AS DROP TYPE IF EXISTS subscription_status CASCADE; CREATE TYPE subscription_status AS ENUM ('unconfirmed', 'confirmed', 'unsubscribed'); DROP TYPE IF EXISTS campaign_status CASCADE; CREATE TYPE campaign_status AS ENUM ('draft', 'running', 'scheduled', 'paused', 'cancelled', 'finished'); DROP TYPE IF EXISTS campaign_type CASCADE; CREATE TYPE campaign_type AS ENUM ('regular', 'optin'); -DROP TYPE IF EXISTS content_type CASCADE; CREATE TYPE content_type AS ENUM ('richtext', 'html', 'plain', 'markdown', 'visual'); +DROP TYPE IF EXISTS content_type CASCADE; CREATE TYPE content_type AS ENUM ('richtext', 'html', 'plain', 'markdown', 'visual', 'mjml'); DROP TYPE IF EXISTS bounce_type CASCADE; CREATE TYPE bounce_type AS ENUM ('soft', 'hard', 'complaint'); DROP TYPE IF EXISTS template_type CASCADE; CREATE TYPE template_type AS ENUM ('campaign', 'campaign_visual', 'tx'); DROP TYPE IF EXISTS user_type CASCADE; CREATE TYPE user_type AS ENUM ('user', 'api'); diff --git a/static/email-templates/sample-mjml.tpl b/static/email-templates/sample-mjml.tpl new file mode 100644 index 000000000..cd9fce4f2 --- /dev/null +++ b/static/email-templates/sample-mjml.tpl @@ -0,0 +1,41 @@ + + + {{ .Campaign.Subject }} + {{ .Campaign.Subject }} + + + + + +   + + + + + + + {{ template "content" . }} + + + + + + + + {{ L.T "email.unsub" }} +    + {{ L.T "email.viewInBrowser" }} + + + + + + + + +  {{ TrackView }} + + + + + From 566f1ddec0e747b3e6c7d4926a5d0c79b04a1d57 Mon Sep 17 00:00:00 2001 From: Dan Fabulich Date: Tue, 7 Jul 2026 23:54:46 -0700 Subject: [PATCH 2/2] Run regTplFuncs on MJML campaign body (#3131) --- models/campaigns.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/models/campaigns.go b/models/campaigns.go index 8add87a40..4c15fbbb9 100644 --- a/models/campaigns.go +++ b/models/campaigns.go @@ -172,7 +172,11 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error { // the campaign body for the `{{ template "content" . }}` placeholder in // the base, then run mjml.Render once. if c.ContentType == CampaignContentTypeMJML { - b := regexpTplTag.ReplaceAllLiteralString(body, c.Body) + campBody := c.Body + for _, r := range regTplFuncs { + campBody = r.regExp.ReplaceAllString(campBody, r.replace) + } + b := regexpTplTag.ReplaceAllLiteralString(body, campBody) htmlBody, err := mjml.Render(b) if err != nil { return fmt.Errorf("error compiling MJML: %v", err)