mirror of
https://github.com/knadh/listmonk.git
synced 2025-12-05 16:00:03 +01:00
Add MJML templating support to campaigns.
This commit is contained in:
@@ -609,7 +609,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
|
||||
}
|
||||
|
||||
|
||||
@@ -235,6 +235,15 @@ func installTemplates(q *models.Queries) (int, int) {
|
||||
lo.Fatalf("error creating default campaign template: %v", err)
|
||||
}
|
||||
|
||||
// Insert MLML 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
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@ export default {
|
||||
case 'html':
|
||||
langs = [html()];
|
||||
break;
|
||||
case 'mjml':
|
||||
langs = [html()];
|
||||
break;
|
||||
case 'css':
|
||||
langs = [css()];
|
||||
break;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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());
|
||||
@@ -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, '<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));
|
||||
}
|
||||
|
||||
@@ -340,6 +340,7 @@ export default Vue.extend({
|
||||
markdown: this.$t('campaigns.markdown'),
|
||||
plain: this.$t('campaigns.plainText'),
|
||||
visual: this.$t('campaigns.visual'),
|
||||
mjml: 'MJML',
|
||||
}),
|
||||
|
||||
isNew: false,
|
||||
|
||||
3
go.mod
3
go.mod
@@ -1,6 +1,6 @@
|
||||
module github.com/knadh/listmonk
|
||||
|
||||
go 1.24.1
|
||||
go 1.24.4
|
||||
|
||||
require (
|
||||
github.com/Masterminds/sprig/v3 v3.3.0
|
||||
@@ -29,6 +29,7 @@ require (
|
||||
github.com/labstack/echo/v4 v4.13.4
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/paulbellamy/ratecounter v0.2.0
|
||||
github.com/preslavrachev/gomjml v0.10.0
|
||||
github.com/rhnvrm/simples3 v0.9.1
|
||||
github.com/spf13/pflag v1.0.6
|
||||
github.com/yuin/goldmark v1.7.12
|
||||
|
||||
8
go.sum
8
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 v0.2.2 h1:KY7a7jFUf6tFKZF6MzuZMhSWuGMv0MtVkK/Kj4Oas38=
|
||||
github.com/altcha-org/altcha-lib-go v0.2.2/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/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
|
||||
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -106,6 +110,10 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v
|
||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/preslavrachev/gomjml v0.5.0 h1:Ca6OxHx7AAK1R3KHx6aRBU6zTex/kezWIp7Z14GrUQM=
|
||||
github.com/preslavrachev/gomjml v0.5.0/go.mod h1:10tpMJhl+46mqf+5wG18fOXaWNB+OOllCpksDRJlJTU=
|
||||
github.com/preslavrachev/gomjml v0.10.0 h1:GdcLph92E3aADmBgR6DnDS7NsnK1DsHeYWmDhZGrJEA=
|
||||
github.com/preslavrachev/gomjml v0.10.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=
|
||||
|
||||
@@ -50,5 +50,10 @@ func V5_1_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
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -16,5 +16,21 @@ func V5_2_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`
|
||||
ALTER TYPE content_type ADD VALUE IF NOT EXISTS 'mjml';
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert MLML 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) VALUES($1, $2, $3, $4)`,
|
||||
"Sample MJML template", "campaign", "", tpl.ReadBytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/jmoiron/sqlx/types"
|
||||
"github.com/lib/pq"
|
||||
"github.com/preslavrachev/gomjml/mjml"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
@@ -49,6 +50,7 @@ const (
|
||||
CampaignContentTypeMarkdown = "markdown"
|
||||
CampaignContentTypePlain = "plain"
|
||||
CampaignContentTypeVisual = "visual"
|
||||
CampaignContentTypeMJML = "mjml"
|
||||
|
||||
// List.
|
||||
ListTypePrivate = "private"
|
||||
@@ -545,19 +547,37 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error {
|
||||
body = r.regExp.ReplaceAllString(body, r.replace)
|
||||
}
|
||||
|
||||
// Parse the base template also as MJML if the campaign content type is MJML.
|
||||
if c.ContentType == CampaignContentTypeMJML {
|
||||
htmlBody, err := mjml.Render(body)
|
||||
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 {
|
||||
// If the campaign format is markdown, convert Markdown to HTML.
|
||||
switch c.ContentType {
|
||||
case CampaignContentTypeMarkdown:
|
||||
var b bytes.Buffer
|
||||
if err := markdown.Convert([]byte(c.Body), &b); err != nil {
|
||||
return err
|
||||
}
|
||||
body = b.String()
|
||||
} else {
|
||||
|
||||
// Is it MJML? Convert to HTML.
|
||||
case CampaignContentTypeMJML:
|
||||
htmlBody, err := mjml.Render(c.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error compiling MJML: %v", err)
|
||||
}
|
||||
body = htmlBody
|
||||
default:
|
||||
body = c.Body
|
||||
}
|
||||
|
||||
@@ -609,6 +629,13 @@ func (c *Campaign) ConvertContent(from, to string) (string, error) {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -4,7 +4,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');
|
||||
|
||||
41
static/email-templates/sample-mjml.tpl
Normal file
41
static/email-templates/sample-mjml.tpl
Normal file
@@ -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> </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>
|
||||
|
||||
<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>
|
||||
{{ TrackView }}
|
||||
</mj-raw>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
Reference in New Issue
Block a user