diff --git a/cmd/campaigns.go b/cmd/campaigns.go index feb606a5..e96a5ab0 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -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 } diff --git a/frontend/src/components/CodeEditor.vue b/frontend/src/components/CodeEditor.vue index 621e9b13..ec853091 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 1a1c6fe1..d11f668e 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 739eca13..1356d3c0 100644 --- a/frontend/src/views/Campaign.vue +++ b/frontend/src/views/Campaign.vue @@ -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, diff --git a/go.mod b/go.mod index f5e41b0b..edbaaf26 100644 --- a/go.mod +++ b/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.5.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 bb125a36..fda62654 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 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,8 @@ 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/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/v5.1.0.go b/internal/migrations/v5.1.0.go index 5b93e9e0..322f035c 100644 --- a/internal/migrations/v5.1.0.go +++ b/internal/migrations/v5.1.0.go @@ -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 } diff --git a/models/models.go b/models/models.go index acd8c0f9..4fd55cd7 100644 --- a/models/models.go +++ b/models/models.go @@ -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" @@ -557,6 +559,13 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error { return err } body = b.String() + } else if c.ContentType == CampaignContentTypeMJML { + // If the format is MJML, convert MJML to HTML. + htmlBody, err := mjml.Render(c.Body) + if err != nil { + return fmt.Errorf("error compiling MJML: %v", err) + } + body = htmlBody } else { body = c.Body } @@ -609,6 +618,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") } diff --git a/schema.sql b/schema.sql index b12e5641..fcb457c1 100644 --- a/schema.sql +++ b/schema.sql @@ -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');