Skip to content

Commit 35d784c

Browse files
committed
Add MJML templating support to campaigns.
1 parent ee099f6 commit 35d784c

12 files changed

Lines changed: 142 additions & 10 deletions

File tree

cmd/campaigns.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -683,7 +683,8 @@ func (a *App) validateCampaignFields(c campReq) (campReq, error) {
683683
c.ContentType != models.CampaignContentTypeHTML &&
684684
c.ContentType != models.CampaignContentTypePlain &&
685685
c.ContentType != models.CampaignContentTypeVisual &&
686-
c.ContentType != models.CampaignContentTypeMarkdown {
686+
c.ContentType != models.CampaignContentTypeMarkdown &&
687+
c.ContentType != models.CampaignContentTypeMJML {
687688
c.ContentType = models.CampaignContentTypeRichtext
688689
}
689690

cmd/install.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,15 @@ func installTemplates(q *models.Queries) (int, int) {
237237
lo.Fatalf("error creating default campaign template: %v", err)
238238
}
239239

240+
// Insert MJML template.
241+
tpl, err := fs.Get("/static/email-templates/sample-mjml.tpl")
242+
if err != nil {
243+
lo.Fatalf("error reading sample mjml template: %v", err)
244+
}
245+
if _, err := q.CreateTemplate.Exec("Sample MJML template", models.TemplateTypeCampaign, "", tpl.ReadBytes(), nil); err != nil {
246+
lo.Fatalf("error creating mjml campaign template: %v", err)
247+
}
248+
240249
return campTplID, archiveTplID
241250
}
242251

frontend/src/components/CodeEditor.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ export default {
5050
case 'html':
5151
langs = [html()];
5252
break;
53+
case 'mjml':
54+
langs = [html()];
55+
break;
5356
case 'css':
5457
langs = [css()];
5558
break;

frontend/src/components/Editor.vue

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@
6767
<!-- raw html editor //-->
6868
<code-editor lang="html" v-if="self.contentType === 'html'" v-model="self.body" key="editor-html" />
6969

70+
<!-- mjml editor //-->
71+
<code-editor lang="mjml" v-if="self.contentType === 'mjml'" v-model="self.body" key="editor-mjml" />
72+
7073
<!-- markdown editor //-->
7174
<code-editor lang="markdown" v-if="self.contentType === 'markdown'" v-model="self.body" key="editor-markdown" />
7275

@@ -162,7 +165,7 @@ export default {
162165
163166
// If `from` is HTML content, strip out `<body>..` etc. and keep the beautified HTML.
164167
let isHTML = false;
165-
if (from === 'richtext' || from === 'html' || from === 'visual') {
168+
if (from === 'richtext' || from === 'html' || from === 'visual' || from === 'mjml') {
166169
const d = document.createElement('div');
167170
d.innerHTML = body;
168171
body = this.beautifyHTML(d.innerHTML.trim());
@@ -198,7 +201,7 @@ export default {
198201
}
199202
200203
// Markdown to HTML requires a backend call.
201-
} else if (from === 'markdown' && (to === 'richtext' || to === 'html')) {
204+
} else if (from === 'markdown' && (to === 'richtext' || to === 'html' || to === 'mjml')) {
202205
skip = true;
203206
this.$api.convertCampaignContent({
204207
id: 1, body, from, to,
@@ -212,8 +215,21 @@ export default {
212215
});
213216
214217
// Plain to an HTML type, change plain line breaks to HTML breaks.
215-
} else if (from === 'plain' && (to === 'richtext' || to === 'html')) {
218+
} else if (from === 'plain' && (to === 'richtext' || to === 'html' || to === 'mjml')) {
216219
body = body.replace(/\n/ig, '<br>\n');
220+
} else if (from === 'mjml' && (to === 'richtext' || to === 'html')) {
221+
// MJML to HTML requires a backend call.
222+
skip = true;
223+
this.$api.convertCampaignContent({
224+
id: 1, body, from, to,
225+
}).then((data) => {
226+
this.$nextTick(() => {
227+
// Both type + body should be updated in one cycle to avoid firing
228+
// multiple events.
229+
this.self.contentType = to;
230+
this.self.body = this.beautifyHTML(data.trim());
231+
});
232+
});
217233
} else if (to === 'visual') {
218234
bodySource = JSON.stringify(markdownToVisualBlock(body));
219235
}

frontend/src/views/Campaign.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@ export default Vue.extend({
349349
markdown: this.$t('campaigns.markdown'),
350350
plain: this.$t('campaigns.plainText'),
351351
visual: this.$t('campaigns.visual'),
352+
mjml: 'MJML',
352353
}),
353354
354355
isNew: false,

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ require (
3030
github.com/lib/pq v1.10.9
3131
github.com/paulbellamy/ratecounter v0.2.0
3232
github.com/pquerna/otp v1.5.0
33+
github.com/preslavrachev/gomjml v0.11.0
3334
github.com/rhnvrm/simples3 v0.9.1
3435
github.com/spf13/pflag v1.0.6
3536
github.com/yuin/goldmark v1.7.12

go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
88
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
99
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
1010
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
11+
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
12+
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
1113
github.com/altcha-org/altcha-lib-go v1.0.0 h1:7oPti0aUS+YCep8nwt5b9g4jYfCU55ZruWESL8G9K5M=
1214
github.com/altcha-org/altcha-lib-go v1.0.0/go.mod h1:I8ESLVWR9C58uvGufB/AJDPhaSU4+4Oh3DLpVtgwDAk=
15+
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
16+
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
1317
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
1418
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
1519
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
@@ -108,6 +112,12 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
108112
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
109113
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
110114
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
115+
github.com/preslavrachev/gomjml v0.5.0 h1:Ca6OxHx7AAK1R3KHx6aRBU6zTex/kezWIp7Z14GrUQM=
116+
github.com/preslavrachev/gomjml v0.5.0/go.mod h1:10tpMJhl+46mqf+5wG18fOXaWNB+OOllCpksDRJlJTU=
117+
github.com/preslavrachev/gomjml v0.10.0 h1:GdcLph92E3aADmBgR6DnDS7NsnK1DsHeYWmDhZGrJEA=
118+
github.com/preslavrachev/gomjml v0.10.0/go.mod h1:10tpMJhl+46mqf+5wG18fOXaWNB+OOllCpksDRJlJTU=
119+
github.com/preslavrachev/gomjml v0.11.0 h1:U+i141zwYcmr1Ru9KlsTDoV131En9G0WJlfdQ9W8OeQ=
120+
github.com/preslavrachev/gomjml v0.11.0/go.mod h1:10tpMJhl+46mqf+5wG18fOXaWNB+OOllCpksDRJlJTU=
111121
github.com/rhnvrm/simples3 v0.9.1 h1:pYfEe2wTjx8B2zFzUdy4kZn3I3Otd9ZvzIhHkFR85kE=
112122
github.com/rhnvrm/simples3 v0.9.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
113123
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=

internal/migrations/v6.2.0.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/knadh/stuffbin"
99
)
1010

11+
// V6_2_0 performs the DB migrations for v6.2.0.
1112
func V6_2_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
1213
// Add `msg_retry_delay` to each SMTP server entry in the `smtp` settings JSON array.
1314
// Idempotent: only updates rows where at least one entry is missing the key.
@@ -27,5 +28,23 @@ func V6_2_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger
2728
return err
2829
}
2930

31+
// Add MJML to content_type enum if not exists.
32+
if _, err := db.Exec(`ALTER TYPE content_type ADD VALUE IF NOT EXISTS 'mjml';`); err != nil {
33+
return err
34+
}
35+
36+
// Insert sample MJML template.
37+
tpl, err := fs.Get("/static/email-templates/sample-mjml.tpl")
38+
if err != nil {
39+
return err
40+
}
41+
if _, err := db.Exec(`
42+
INSERT INTO templates (name, type, subject, body)
43+
SELECT $1, $2::template_type, $3, $4
44+
WHERE NOT EXISTS (SELECT 1 FROM templates WHERE name = $1 AND type = $2::template_type);`,
45+
"Sample MJML template", "campaign", "", tpl.ReadBytes()); err != nil {
46+
return err
47+
}
48+
3049
return nil
3150
}

models/campaigns.go

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/jmoiron/sqlx"
1313
"github.com/jmoiron/sqlx/types"
1414
"github.com/lib/pq"
15+
"github.com/preslavrachev/gomjml/mjml"
1516
null "gopkg.in/volatiletech/null.v6"
1617
)
1718

@@ -29,6 +30,7 @@ const (
2930
CampaignContentTypeMarkdown = "markdown"
3031
CampaignContentTypePlain = "plain"
3132
CampaignContentTypeVisual = "visual"
33+
CampaignContentTypeMJML = "mjml"
3234
)
3335

3436
// Campaigns represents a slice of Campaigns.
@@ -156,28 +158,46 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error {
156158

157159
// Compile the base template.
158160
body := c.TemplateBody
161+
hasBaseTpl := body != ""
159162

160-
if body == "" || c.ContentType == CampaignContentTypeVisual {
163+
if !hasBaseTpl || c.ContentType == CampaignContentTypeVisual {
161164
body = `{{ template "content" . }}`
162165
}
163166

164167
for _, r := range regTplFuncs {
165168
body = r.regExp.ReplaceAllString(body, r.replace)
166169
}
167170

171+
// For an MJML campaign, the entire body is one MJML document. Substitute
172+
// the campaign body for the `{{ template "content" . }}` placeholder in
173+
// the base, then run mjml.Render once.
174+
if c.ContentType == CampaignContentTypeMJML {
175+
b := regexpTplTag.ReplaceAllLiteralString(body, c.Body)
176+
htmlBody, err := mjml.Render(b)
177+
if err != nil {
178+
return fmt.Errorf("error compiling MJML: %v", err)
179+
}
180+
body = htmlBody
181+
}
182+
168183
baseTPL, err := template.New(BaseTpl).Funcs(f).Parse(body)
169184
if err != nil {
170185
return fmt.Errorf("error compiling base template: %v", err)
171186
}
172187

173-
// If the format is markdown, convert Markdown to HTML.
174-
if c.ContentType == CampaignContentTypeMarkdown {
188+
// Pick the body to assign to the `content` sub-template.
189+
switch c.ContentType {
190+
case CampaignContentTypeMJML:
191+
body = ""
192+
193+
case CampaignContentTypeMarkdown:
175194
var b bytes.Buffer
176195
if err := markdown.Convert([]byte(c.Body), &b); err != nil {
177196
return err
178197
}
179198
body = b.String()
180-
} else {
199+
200+
default:
181201
body = c.Body
182202
}
183203

@@ -258,12 +278,19 @@ func (c *Campaign) ConvertContent(from, to string) (string, error) {
258278
// If the format is markdown, convert Markdown to HTML.
259279
var out string
260280
if from == CampaignContentTypeMarkdown &&
261-
(to == CampaignContentTypeHTML || to == CampaignContentTypeRichtext) {
281+
(to == CampaignContentTypeHTML || to == CampaignContentTypeRichtext || to == CampaignContentTypeMJML) {
262282
var b bytes.Buffer
263283
if err := markdown.Convert([]byte(c.Body), &b); err != nil {
264284
return out, err
265285
}
266286
out = b.String()
287+
} else if from == CampaignContentTypeMJML &&
288+
(to == CampaignContentTypeHTML || to == CampaignContentTypeRichtext) {
289+
htmlBody, err := mjml.Render(c.Body)
290+
if err != nil {
291+
return out, fmt.Errorf("error converting MJML: %v", err)
292+
}
293+
out = htmlBody
267294
} else {
268295
return out, errors.New("unknown formats to convert")
269296
}

models/common.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ type regTplFunc struct {
4040
replace string
4141
}
4242

43+
// regexpTplTag matches a `{{ template "content" . }}` directive with any
44+
// amount of internal whitespace.
45+
var regexpTplTag = regexp.MustCompile(`{{\s*template\s+"content"\s+\.\s*}}`)
46+
4347
var regTplFuncs = []regTplFunc{
4448
// Regular expression for matching {{ TrackLink "http://link.com" }} in the template
4549
// and substituting it with {{ TrackLink "http://link.com" . }} (the dot context)

0 commit comments

Comments
 (0)