Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions cmd/campaigns.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/CodeEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export default {
case 'html':
langs = [html()];
break;
case 'mjml':
langs = [html()];
break;
case 'css':
langs = [css()];
break;
Expand Down
22 changes: 19 additions & 3 deletions frontend/src/components/Editor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@
<!-- raw html editor //-->
<code-editor lang="html" v-if="self.contentType === 'html'" v-model="self.body" key="editor-html" />

<!-- mjml editor //-->
<code-editor lang="mjml" v-if="self.contentType === 'mjml'" v-model="self.body" key="editor-mjml" />

<!-- markdown editor //-->
<code-editor lang="markdown" v-if="self.contentType === 'markdown'" v-model="self.body" key="editor-markdown" />

Expand Down Expand Up @@ -162,7 +165,7 @@ export default {

// If `from` is HTML content, strip out `<body>..` 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());
Expand Down Expand Up @@ -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,
Expand All @@ -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, '<br>\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));
}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/views/Campaign.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
19 changes: 19 additions & 0 deletions internal/migrations/v6.2.0.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
41 changes: 36 additions & 5 deletions models/campaigns.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -29,6 +30,7 @@ const (
CampaignContentTypeMarkdown = "markdown"
CampaignContentTypePlain = "plain"
CampaignContentTypeVisual = "visual"
CampaignContentTypeMJML = "mjml"
)

// Campaigns represents a slice of Campaigns.
Expand Down Expand Up @@ -156,28 +158,50 @@ 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" . }}`
}

for _, r := range regTplFuncs {
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 {
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)
}
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
}

Expand Down Expand Up @@ -258,12 +282,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")
}
Expand Down
4 changes: 4 additions & 0 deletions models/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
41 changes: 41 additions & 0 deletions static/email-templates/sample-mjml.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<mjml>
<mj-head>
<mj-title>{{ .Campaign.Subject }}</mj-title>
<mj-preview>{{ .Campaign.Subject }}</mj-preview>
</mj-head>
<mj-body background-color="#F0F1F3">
<!-- Spacer -->
<mj-section padding="30px 0">
<mj-column>
<mj-text>&nbsp;</mj-text>
</mj-column>
</mj-section>

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

<!-- Footer -->
<mj-section padding="20px 0">
<mj-column>
<mj-text align="center" font-size="12px" color="#888">
<a href="{{ UnsubscribeURL }}" style="color: #888; margin-right: 5px;">{{ L.T "email.unsub" }}</a>
&nbsp;&nbsp;
<a href="{{ MessageURL }}" style="color: #888; margin-right: 5px;">{{ L.T "email.viewInBrowser" }}</a>
</mj-text>
</mj-column>
</mj-section>

<!-- Bottom Spacer with Tracking -->
<mj-section padding="30px 0">
<mj-column>
<mj-raw>
&nbsp;{{ TrackView }}
</mj-raw>
</mj-column>
</mj-section>
</mj-body>
</mjml>
Loading