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
3 changes: 2 additions & 1 deletion cmd/campaigns.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

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

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 @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 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 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=
Expand Down Expand Up @@ -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=
Expand Down
5 changes: 5 additions & 0 deletions internal/migrations/v5.1.0.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 16 additions & 0 deletions internal/migrations/v5.2.0.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
33 changes: 30 additions & 3 deletions models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -49,6 +50,7 @@ const (
CampaignContentTypeMarkdown = "markdown"
CampaignContentTypePlain = "plain"
CampaignContentTypeVisual = "visual"
CampaignContentTypeMJML = "mjml"

// List.
ListTypePrivate = "private"
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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")
}
Expand Down
2 changes: 1 addition & 1 deletion schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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');
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