Skip to content

Add full fledged visual e-mail editor #2373

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 27 commits into from
Apr 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ae98280
feat: Integrate email-builder on campaign/template editor UI and back…
vividvilla Oct 15, 2024
e6f08a0
feat: Inject email-builder instead of loading it as ES module
vividvilla Oct 31, 2024
4a524c9
feat: Integrate media selector to visual editor and add minor fixes.
vividvilla Feb 24, 2025
82e2b70
fix: Move visual-editor to iframe so that CSS styles are isolated.
vividvilla Apr 6, 2025
c1f81cf
Fix compatibility issues with `master`.
knadh Apr 6, 2025
7786b18
Add DB migrations for visual editor.
knadh Apr 7, 2025
fca5ec5
Change visual editor UI language.
knadh Apr 7, 2025
110345d
Refactor and simplify state management in campaign editor.
knadh Apr 7, 2025
cee2589
Move `email-builder` src from `/` to `/frontend`.
knadh Apr 7, 2025
f1fbadf
Fix incorrect template states in DB in campaign creation and broken p…
knadh Apr 7, 2025
503e985
Add support for converting all types to visual editor blocks.
knadh Apr 7, 2025
e4e735e
Update tsx formatting in email-builder.
knadh Apr 7, 2025
343d405
Add Ctrl+S campaign save shortcut and add Ctrl+S and F9 to richtext e…
knadh Apr 7, 2025
3aba2b0
Fix frontend/email-builder Makefile build steps.
knadh Apr 8, 2025
f4c0e66
Refactor 'media upload' integration in campaign visual editor.
knadh Apr 8, 2025
20e4b10
Fix DB state issues in visual campaign cloning.
knadh Apr 8, 2025
968b366
Stop fetching bodies for all campaigns and explicitly fetch for visua…
knadh Apr 10, 2025
5207dff
Fix visual editor change event/init/import sequences.
knadh Apr 14, 2025
d3da0be
Fix `test campaign' sending wrong template for visual campaigns.
knadh Apr 14, 2025
dcdef8e
Add format/content type selection to campaign creation UI.
knadh Apr 14, 2025
ded0fcf
Fix broken visual template cloning on the templates UI.
knadh Apr 14, 2025
1559c55
Add visual editor Cypress tests.
knadh Apr 14, 2025
445d7e5
Fix `clone campaign` by fetching campaign body on clone.
knadh Apr 14, 2025
ffbda01
Add sample visual campaign template on install and upgrade.
knadh Apr 18, 2025
53d7929
Prepare first RC.
knadh Apr 18, 2025
b67fc5e
Replace `CodeFlask` code editor with `CodeMirror` on the UI.
knadh Apr 20, 2025
ed700d7
Add a `Preview` option to the campaign archive tab. Closes #2245.
knadh Apr 21, 2025
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
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@ frontend/node_modules/
frontend/.cache/
frontend/yarn.lock
frontend/build/
frontend/public/static/email-builder/
frontend/dist/
email-builder/node_modules/
email-builder/.cache/
email-builder/yarn.lock
email-builder/dist/
.vscode/

config.toml
node_modules
listmonk
dist/*
dist/*
uploads/
33 changes: 31 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,26 @@ GOPATH ?= $(HOME)/go
STUFFBIN ?= $(GOPATH)/bin/stuffbin
FRONTEND_YARN_MODULES = frontend/node_modules
FRONTEND_DIST = frontend/dist
FRONTEND_EMAIL_BUILDER_DIST_FINAL = frontend/public/static/email-builder
FRONTEND_DEPS = \
$(FRONTEND_YARN_MODULES) \
$(FRONTEND_EMAIL_BUILDER_DIST_FINAL) \
frontend/index.html \
frontend/package.json \
frontend/vite.config.js \
frontend/.eslintrc.js \
$(shell find frontend/fontello frontend/public frontend/src -type f)

FRONTEND_EMAIL_BUILDER = frontend/email-builder
FRONTEND_EMAIL_BUILDER_YARN_MODULES = $(FRONTEND_EMAIL_BUILDER)/node_modules
FRONTEND_EMAIL_BUILDER_DIST = $(FRONTEND_EMAIL_BUILDER)/dist
FRONTEND_EMAIL_BUILDER_DEPS = \
$(FRONTEND_EMAIL_BUILDER_YARN_MODULES) \
$(FRONTEND_EMAIL_BUILDER)/package.json \
$(FRONTEND_EMAIL_BUILDER)/tsconfig.json \
$(FRONTEND_EMAIL_BUILDER)/vite.config.ts \
$(shell find $(FRONTEND_EMAIL_BUILDER)/src -type f)

BIN := listmonk
STATIC := config.toml.sample \
schema.sql queries.sql permissions.json \
Expand All @@ -37,6 +49,10 @@ $(FRONTEND_YARN_MODULES): frontend/package.json frontend/yarn.lock
cd frontend && $(YARN) install
touch -c $(FRONTEND_YARN_MODULES)

$(FRONTEND_EMAIL_BUILDER_YARN_MODULES): frontend/package.json frontend/yarn.lock
cd $(FRONTEND_EMAIL_BUILDER) && $(YARN) install
touch -c $(FRONTEND_EMAIL_BUILDER_YARN_MODULES)

# Build the backend to ./listmonk.
$(BIN): $(shell find . -type f -name "*.go") go.mod go.sum schema.sql queries.sql permissions.json
CGO_ENABLED=0 go build -o ${BIN} -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}'" cmd/*.go
Expand All @@ -51,13 +67,26 @@ $(FRONTEND_DIST): $(FRONTEND_DEPS)
export VUE_APP_VERSION="${VERSION}" && cd frontend && $(YARN) build
touch -c $(FRONTEND_DIST)

# Build the JS email-builder dist.
$(FRONTEND_EMAIL_BUILDER_DIST): $(FRONTEND_EMAIL_BUILDER_DEPS)
export VUE_APP_VERSION="${VERSION}" && cd $(FRONTEND_EMAIL_BUILDER) && $(YARN) build
touch -c $(FRONTEND_EMAIL_BUILDER_DIST)

# Copy the build assets to frontend.
$(FRONTEND_EMAIL_BUILDER_DIST_FINAL): $(FRONTEND_EMAIL_BUILDER_DIST)
mkdir -p $(FRONTEND_EMAIL_BUILDER_DIST_FINAL)
cp -r $(FRONTEND_EMAIL_BUILDER_DIST)/* $(FRONTEND_EMAIL_BUILDER_DIST_FINAL)
touch -c $(FRONTEND_EMAIL_BUILDER_DIST_FINAL)

.PHONY: build-frontend
build-frontend: $(FRONTEND_DIST)
build-frontend: $(FRONTEND_EMAIL_BUILDER_DIST_FINAL) $(FRONTEND_DIST)

.PHONY: build-email-builder
build-email-builder: $(FRONTEND_EMAIL_BUILDER_DIST_FINAL)

# Run the JS frontend server in dev mode.
.PHONY: run-frontend
run-frontend:
run-frontend: $(FRONTEND_EMAIL_BUILDER_DIST_FINAL)
export VUE_APP_VERSION="${VERSION}" && cd frontend && $(YARN) dev

# Run Go tests.
Expand Down
77 changes: 69 additions & 8 deletions cmd/campaigns.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ func (a *App) GetCampaigns(c echo.Context) error {
if noBody {
for i := range res {
res[i].Body = ""
res[i].BodySource.Valid = false
}
}

Expand Down Expand Up @@ -142,17 +143,31 @@ func (a *App) PreviewCampaign(c echo.Context) error {
return err
}

// Fetch the campaign body from the DB.
tplID, _ := strconv.Atoi(c.FormValue("template_id"))
var (
isPost = c.Request().Method == http.MethodPost
contentType = c.FormValue("content_type")
tplID, _ = strconv.Atoi(c.FormValue("template_id"))
)
// For visual content, template ID for previewing is irrelevant.
if contentType == models.CampaignContentTypeVisual || tplID < 1 {
tplID = 0
}

// Get the campaign from the DB for previewing with the `template_body` field.
camp, err := a.core.GetCampaignForPreview(id, tplID)
if err != nil {
return err
}

// There's a body in the request to preview instead of the body in the DB.
if c.Request().Method == http.MethodPost {
camp.ContentType = c.FormValue("content_type")
if isPost {
camp.ContentType = contentType
camp.Body = c.FormValue("body")

// For visual campaigns, template body from the DB shouldn't be used.
if contentType == models.CampaignContentTypeVisual {
camp.TemplateBody = ""
}
}

// Use a dummy campaign ID to prevent views and clicks from {{ TrackView }}
Expand All @@ -172,13 +187,52 @@ func (a *App) PreviewCampaign(c echo.Context) error {
a.i18n.Ts("templates.errorRendering", "error", err.Error()))
}

// Plaintext headers for plain body.
if camp.ContentType == models.CampaignContentTypePlain {
return c.String(http.StatusOK, string(msg.Body()))
}

return c.HTML(http.StatusOK, string(msg.Body()))
}

// PreviewCampaignArchive renders the public campaign archives page.
func (a *App) PreviewCampaignArchive(c echo.Context) error {
// Get the campaign ID.
id := getID(c)

// Check if the user has access to the campaign.
if err := a.checkCampaignPerm(auth.PermTypeGet, id, c); err != nil {
return err
}

// Fetch the campaign body from the DB.
tplID, _ := strconv.Atoi(c.FormValue("template_id"))
camp, err := a.core.GetCampaignForPreview(id, tplID)
if err != nil {
return err
}

camp.ArchiveMeta = json.RawMessage([]byte(c.FormValue("archive_meta")))

// "Compile" the campaign template with appropriate data.
res, err := a.compileArchiveCampaigns([]models.Campaign{camp})
if err != nil {
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorFetchingCampaign")))
}

// Render the campaign body.
out := res[0].Campaign
msg, err := a.manager.NewCampaignMessage(out, res[0].Subscriber)
if err != nil {
a.log.Printf("error rendering campaign: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(a.i18n.T("public.errorTitle"), "", a.i18n.Ts("public.errorFetchingCampaign")))
}

return c.HTML(http.StatusOK, string(msg.Body()))
}

// CampaignContent handles campaign content (body) format conversions.
func (a *App) CampaignContent(c echo.Context) error {
var camp campContentReq
Expand Down Expand Up @@ -214,9 +268,6 @@ func (a *App) CreateCampaign(c echo.Context) error {
o.Type = models.CampaignTypeRegular
}

if o.ContentType == "" {
o.ContentType = models.CampaignContentTypeRichtext
}
if o.Messenger == "" {
o.Messenger = "email"
}
Expand All @@ -228,7 +279,7 @@ func (a *App) CreateCampaign(c echo.Context) error {
o = c
}

if o.ArchiveTemplateID == 0 {
if o.ArchiveTemplateID.Valid && o.ArchiveTemplateID.Int != 0 {
o.ArchiveTemplateID = o.TemplateID
}

Expand Down Expand Up @@ -553,6 +604,16 @@ func (a *App) validateCampaignFields(c campReq) (campReq, error) {
return c, errors.New(a.i18n.T("campaigns.fieldInvalidSubject"))
}

// If no content-type is specified, default to richtext.
if c.ContentType != models.CampaignContentTypeRichtext &&
c.ContentType != models.CampaignContentTypeHTML &&
c.ContentType != models.CampaignContentTypePlain &&
c.ContentType != models.CampaignContentTypeVisual &&
c.ContentType != models.CampaignContentTypeMarkdown {
c.ContentType = models.CampaignContentTypeRichtext
c.BodySource.Valid = false
}

// If there's a "send_at" date, it should be in the future.
if c.SendAt.Valid {
if c.SendAt.Time.Before(time.Now()) {
Expand Down
1 change: 1 addition & 0 deletions cmd/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ func initHTTPHandlers(e *echo.Echo, a *App) {
g.GET("/api/campaigns/:id", pm(hasID(a.GetCampaign), "campaigns:get_all", "campaigns:get"))
g.GET("/api/campaigns/analytics/:type", pm(a.GetCampaignViewAnalytics, "campaigns:get_analytics"))
g.GET("/api/campaigns/:id/preview", pm(hasID(a.PreviewCampaign), "campaigns:get_all", "campaigns:get"))
g.POST("/api/campaigns/:id/preview/archive", pm(hasID(a.PreviewCampaignArchive), "campaigns:get_all", "campaigns:get"))
g.POST("/api/campaigns/:id/preview", pm(hasID(a.PreviewCampaign), "campaigns:get_all", "campaigns:get"))
g.POST("/api/campaigns/:id/content", pm(hasID(a.CampaignContent), "campaigns:manage_all", "campaigns:manage"))
g.POST("/api/campaigns/:id/text", pm(hasID(a.PreviewCampaign), "campaigns:get"))
Expand Down
4 changes: 2 additions & 2 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ func initDB() *sqlx.DB {
db.SetMaxIdleConns(c.MaxIdle)
db.SetConnMaxLifetime(c.MaxLifetime)

return db
return db.Unsafe()
}

// readQueries reads named SQL queries from the SQL queries file into a query map.
Expand Down Expand Up @@ -358,7 +358,7 @@ func prepareQueries(qMap goyesql.Queries, db *sqlx.DB, ko *koanf.Koanf) *models.

// Scan and prepare all queries.
var q models.Queries
if err := goyesqlx.ScanToStruct(&q, qMap, db.Unsafe()); err != nil {
if err := goyesqlx.ScanToStruct(&q, qMap, db); err != nil {
lo.Fatalf("error preparing SQL queries: %v", err)
}

Expand Down
21 changes: 18 additions & 3 deletions cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ func installTemplates(q *models.Queries) (int, int) {
}

var campTplID int
if err := q.CreateTemplate.Get(&campTplID, "Default campaign template", models.TemplateTypeCampaign, "", campTpl.ReadBytes()); err != nil {
if err := q.CreateTemplate.Get(&campTplID, "Default campaign template", models.TemplateTypeCampaign, "", campTpl.ReadBytes(), nil); err != nil {
lo.Fatalf("error creating default campaign template: %v", err)
}
if _, err := q.SetDefaultTemplate.Exec(campTplID); err != nil {
Expand All @@ -207,7 +207,7 @@ func installTemplates(q *models.Queries) (int, int) {
}

var archiveTplID int
if err := q.CreateTemplate.Get(&archiveTplID, "Default archive template", models.TemplateTypeCampaign, "", archiveTpl.ReadBytes()); err != nil {
if err := q.CreateTemplate.Get(&archiveTplID, "Default archive template", models.TemplateTypeCampaign, "", archiveTpl.ReadBytes(), nil); err != nil {
lo.Fatalf("error creating default campaign template: %v", err)
}

Expand All @@ -217,10 +217,24 @@ func installTemplates(q *models.Queries) (int, int) {
lo.Fatalf("error reading default e-mail template: %v", err)
}

if _, err := q.CreateTemplate.Exec("Sample transactional template", models.TemplateTypeTx, "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes()); err != nil {
if _, err := q.CreateTemplate.Exec("Sample transactional template", models.TemplateTypeTx, "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes(), nil); err != nil {
lo.Fatalf("error creating sample transactional template: %v", err)
}

// Sample visual campaign template.
visualTpl, err := fs.Get("/static/email-templates/default-visual.tpl")
if err != nil {
lo.Fatalf("error reading default visual template: %v", err)
}
visualSrc, err := fs.Get("/static/email-templates/default-visual.json")
if err != nil {
lo.Fatalf("error reading default visual template json: %v", err)
}

if _, err := q.CreateTemplate.Exec("Sample visual template", models.TemplateTypeCampaignVisual, "", visualTpl.ReadBytes(), visualSrc.ReadBytes()); err != nil {
lo.Fatalf("error creating default campaign template: %v", err)
}

return campTplID, archiveTplID
}

Expand Down Expand Up @@ -252,6 +266,7 @@ func installCampaign(campTplID, archiveTplID int, q *models.Queries) {
archiveTplID,
`{"name": "Subscriber"}`,
nil,
nil,
); err != nil {
lo.Fatalf("error creating sample campaign: %v", err)
}
Expand Down
8 changes: 4 additions & 4 deletions cmd/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ func (a *App) CreateTemplate(c echo.Context) error {
// Subject is only relevant for fixed tx templates. For campaigns,
// the subject changes per campaign and is on models.Campaign.
var funcs template.FuncMap
if o.Type == models.TemplateTypeCampaign {
if o.Type == models.TemplateTypeCampaign || o.Type == models.TemplateTypeCampaignVisual {
o.Subject = ""
funcs = a.manager.TemplateFuncs(nil)
} else {
Expand All @@ -130,7 +130,7 @@ func (a *App) CreateTemplate(c echo.Context) error {
}

// Create the template the in the DB.
out, err := a.core.CreateTemplate(o.Name, o.Type, o.Subject, []byte(o.Body))
out, err := a.core.CreateTemplate(o.Name, o.Type, o.Subject, []byte(o.Body), o.BodySource)
if err != nil {
return err
}
Expand Down Expand Up @@ -171,7 +171,7 @@ func (a *App) UpdateTemplate(c echo.Context) error {

// Update the template in the DB.
id := getID(c)
out, err := a.core.UpdateTemplate(id, o.Name, o.Subject, []byte(o.Body))
out, err := a.core.UpdateTemplate(id, o.Name, o.Subject, []byte(o.Body), o.BodySource)
if err != nil {
return err
}
Expand Down Expand Up @@ -232,7 +232,7 @@ func (a *App) validateTemplate(o models.Template) error {
// previewTemplate renders the HTML preview of a template.
func (a *App) previewTemplate(tpl models.Template) ([]byte, error) {
var out []byte
if tpl.Type == models.TemplateTypeCampaign {
if tpl.Type == models.TemplateTypeCampaign || tpl.Type == models.TemplateTypeCampaignVisual {
camp := models.Campaign{
UUID: dummyUUID,
Name: a.i18n.T("templates.dummyName"),
Expand Down
2 changes: 1 addition & 1 deletion cmd/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ var migList = []migFunc{
{"v3.0.0", migrations.V3_0_0},
{"v4.0.0", migrations.V4_0_0},
{"v4.1.0", migrations.V4_1_0},
{"v5.0.0", migrations.V5_0_0},
{"v5.0.0-rc.1", migrations.V5_0_0_rc1},
}

// upgrade upgrades the database to the current version by running SQL migration files
Expand Down
1 change: 1 addition & 0 deletions frontend/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ module.exports = {
comments: 200,
}],
},
ignorePatterns: ['src/email-builder.js'],
};
Loading