diff --git a/cmd/handlers.go b/cmd/handlers.go index 3758401..893603a 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -138,6 +138,8 @@ func initHTTPHandlers(e *echo.Echo, app *App) { g.PUT("/api/templates/:id/default", handleTemplateSetDefault) g.DELETE("/api/templates/:id", handleDeleteTemplate) + g.POST("/api/tx", handleSendTxMessage) + if app.constants.BounceWebhooksEnabled { // Private authenticated bounce endpoint. g.POST("/webhooks/bounce", handleBounceWebhook) diff --git a/cmd/init.go b/cmd/init.go index 4fd17db..a21ea40 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -426,6 +426,20 @@ func initCampaignManager(q *models.Queries, cs *constants, app *App) *manager.Ma }, newManagerStore(q), campNotifCB, app.i18n, lo) } +func initTxTemplates(m *manager.Manager, app *App) { + tpls, err := app.core.GetTemplates(models.TemplateTypeTx, false) + if err != nil { + lo.Fatalf("error loading transactional templates: %v", err) + } + + for _, t := range tpls { + if err := t.Compile(app.manager.GenericTemplateFuncs()); err != nil { + lo.Fatalf("error compiling transactional template %d: %v", t.ID, err) + } + m.CacheTpl(t.ID, &t) + } +} + // initImporter initializes the bulk subscriber importer. func initImporter(q *models.Queries, db *sqlx.DB, app *App) *subimporter.Importer { return subimporter.New( diff --git a/cmd/install.go b/cmd/install.go index 0a82d94..3056d83 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -109,20 +109,17 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo lo.Fatalf("error creating subscriber: %v", err) } - // Default template. - tplBody, err := fs.Get("/static/email-templates/default.tpl") + // Default campaign template. + campTpl, err := fs.Get("/static/email-templates/default.tpl") if err != nil { lo.Fatalf("error reading default e-mail template: %v", err) } - var tplID int - if err := q.CreateTemplate.Get(&tplID, - "Default template", - string(tplBody.ReadBytes()), - ); err != nil { - lo.Fatalf("error creating default template: %v", err) + var campTplID int + if err := q.CreateTemplate.Get(&campTplID, "Default campaign template", models.TemplateTypeCampaign, "", campTpl.ReadBytes()); err != nil { + lo.Fatalf("error creating default campaign template: %v", err) } - if _, err := q.SetDefaultTemplate.Exec(tplID); err != nil { + if _, err := q.SetDefaultTemplate.Exec(campTplID); err != nil { lo.Fatalf("error setting default template: %v", err) } @@ -146,12 +143,22 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo json.RawMessage("[]"), pq.StringArray{"test-campaign"}, emailMsgr, - 1, + campTplID, pq.Int64Array{1}, ); err != nil { lo.Fatalf("error creating sample campaign: %v", err) } + // Sample tx template. + txTpl, err := fs.Get("/static/email-templates/sample-tx.tpl") + if err != nil { + 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 { + lo.Fatalf("error creating sample transactional template: %v", err) + } + lo.Printf("setup complete") lo.Printf(`run the program and access the dashboard at %s`, ko.MustString("app.address")) } diff --git a/cmd/main.go b/cmd/main.go index 8c6b0fb..46311f6 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -189,6 +189,7 @@ func main() { app.manager = initCampaignManager(app.queries, app.constants, app) app.importer = initImporter(app.queries, db, app) app.notifTpls = initNotifTemplates("/email-templates/*.html", fs, app.i18n, app.constants) + initTxTemplates(app.manager, app) if ko.Bool("bounce.enabled") { app.bounce = initBounceManager(app) diff --git a/cmd/templates.go b/cmd/templates.go index e73e23a..bed1690 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -5,6 +5,7 @@ import ( "net/http" "regexp" "strconv" + "strings" "github.com/knadh/listmonk/models" "github.com/labstack/echo/v4" @@ -48,7 +49,7 @@ func handleGetTemplates(c echo.Context) error { return c.JSON(http.StatusOK, okResp{out}) } - out, err := app.core.GetTemplates(noBody) + out, err := app.core.GetTemplates("", noBody) if err != nil { return err } @@ -63,10 +64,15 @@ func handlePreviewTemplate(c echo.Context) error { id, _ = strconv.Atoi(c.Param("id")) body = c.FormValue("body") + typ = c.FormValue("typ") ) + if typ == "" { + typ = models.TemplateTypeCampaign + } + if body != "" { - if !regexpTplTag.MatchString(body) { + if typ == models.TemplateTypeCampaign && !regexpTplTag.MatchString(body) { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("templates.placeholderHelp", "placeholder", tplTag)) } @@ -120,16 +126,33 @@ func handleCreateTemplate(c echo.Context) error { } if err := validateTemplate(o, app); err != nil { - return err + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } - out, err := app.core.CreateTemplate(o.Name, []byte(o.Body)) + // Subject is only relevant for fixed tx templates. For campaigns, + // the subject changes per campaign and is on models.Campaign. + if o.Type == models.TemplateTypeCampaign { + o.Subject = "" + } + + // Compile the template and validate. + if err := o.Compile(app.manager.GenericTemplateFuncs()); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + // Create the template the in the DB. + out, err := app.core.CreateTemplate(o.Name, o.Type, o.Subject, []byte(o.Body)) if err != nil { return err } - return c.JSON(http.StatusOK, okResp{out}) + // If it's a transactional template, cache it in the manager + // to be used for arbitrary incoming tx message pushes. + if o.Type == models.TemplateTypeTx { + app.manager.CacheTpl(out.ID, &o) + } + return c.JSON(http.StatusOK, okResp{out}) } // handleUpdateTemplate handles template modification. @@ -152,11 +175,27 @@ func handleUpdateTemplate(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } - out, err := app.core.UpdateTemplate(id, o.Name, []byte(o.Body)) + // Subject is only relevant for fixed tx templates. For campaigns, + // the subject changes per campaign and is on models.Campaign. + if o.Type == models.TemplateTypeCampaign { + o.Subject = "" + } + + // Compile the template and validate. + if err := o.Compile(app.manager.GenericTemplateFuncs()); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + out, err := app.core.UpdateTemplate(id, o.Name, o.Type, o.Subject, []byte(o.Body)) if err != nil { return err } + // If it's a transactional template, cache it. + if o.Type == models.TemplateTypeTx { + app.manager.CacheTpl(out.ID, &o) + } + return c.JSON(http.StatusOK, okResp{out}) } @@ -194,19 +233,27 @@ func handleDeleteTemplate(c echo.Context) error { return err } + // Delete cached template. + app.manager.DeleteTpl(id) + return c.JSON(http.StatusOK, okResp{true}) } -// validateTemplate validates template fields. +// compileTemplate validates template fields. func validateTemplate(o models.Template, app *App) error { if !strHasLen(o.Name, 1, stdInputMaxLen) { return errors.New(app.i18n.T("campaigns.fieldInvalidName")) } - if !regexpTplTag.MatchString(o.Body) { + if o.Type == models.TemplateTypeCampaign && !regexpTplTag.MatchString(o.Body) { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("templates.placeholderHelp", "placeholder", tplTag)) } + if o.Type == models.TemplateTypeTx && strings.TrimSpace(o.Subject) == "" { + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.missingFields", "name", "subject")) + } + return nil } diff --git a/cmd/tx.go b/cmd/tx.go new file mode 100644 index 0000000..5855a04 --- /dev/null +++ b/cmd/tx.go @@ -0,0 +1,100 @@ +package main + +import ( + "net/http" + "net/textproto" + + "github.com/knadh/listmonk/internal/manager" + "github.com/knadh/listmonk/models" + "github.com/labstack/echo/v4" +) + +// handleSendTxMessage handles the sending of a transactional message. +func handleSendTxMessage(c echo.Context) error { + var ( + app = c.Get("app").(*App) + m models.TxMessage + ) + + if err := c.Bind(&m); err != nil { + return err + } + + // Validate input. + if r, err := validateTxMessage(m, app); err != nil { + return err + } else { + m = r + } + + // Get the cached tx template. + tpl, err := app.manager.GetTpl(m.TemplateID) + if err != nil { + return err + } + + // Get the subscriber. + sub, err := app.core.GetSubscriber(m.SubscriberID, "", m.SubscriberEmail) + if err != nil { + return err + } + + // Render the message. + if err := m.Render(sub, tpl); err != nil { + return err + } + + // Prepare the final message. + msg := manager.Message{} + msg.Subscriber = sub + msg.To = []string{sub.Email} + msg.From = m.FromEmail + msg.Subject = m.Subject + msg.ContentType = m.ContentType + msg.Messenger = m.Messenger + msg.Body = m.Body + + // Optional headers. + if len(m.Headers) != 0 { + msg.Headers = make(textproto.MIMEHeader) + for _, set := range msg.Campaign.Headers { + for hdr, val := range set { + msg.Headers.Add(hdr, val) + } + } + } + + if err := app.manager.PushMessage(msg); err != nil { + app.log.Printf("error sending message (%s): %v", msg.Subject, err) + return err + } + + return c.JSON(http.StatusOK, okResp{true}) +} + +func validateTxMessage(m models.TxMessage, app *App) (models.TxMessage, error) { + if m.SubscriberEmail == "" && m.SubscriberID == 0 { + return m, echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.missingFields", "name", "subscriber_email or subscriber_id")) + } + + if m.SubscriberEmail != "" { + em, err := app.importer.SanitizeEmail(m.SubscriberEmail) + if err != nil { + return m, echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + m.SubscriberEmail = em + } + + if m.FromEmail == "" { + m.FromEmail = app.constants.FromEmail + } + + if m.Messenger == "" { + m.Messenger = emailMsgr + } else if !app.manager.HasMessenger(m.Messenger) { + return m, echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("campaigns.fieldInvalidMessenger", "name", m.Messenger)) + } + + return m, nil +} diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 1b49a25..08d06da 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -32,6 +32,7 @@ var migList = []migFunc{ {"v1.0.0", migrations.V1_0_0}, {"v2.0.0", migrations.V2_0_0}, {"v2.1.0", migrations.V2_1_0}, + {"v2.2.0", migrations.V2_2_0}, } // upgrade upgrades the database to the current version by running SQL migration files diff --git a/frontend/src/assets/style.scss b/frontend/src/assets/style.scss index 8f0d916..6973d19 100644 --- a/frontend/src/assets/style.scss +++ b/frontend/src/assets/style.scss @@ -521,14 +521,14 @@ body.is-noscroll { color: $grey; } - &.private, &.scheduled, &.paused { + &.private, &.scheduled, &.paused, &.tx { $color: #ed7b00; color: $color; background: #fff7e6; border: 1px solid lighten($color, 37%); box-shadow: 1px 1px 0 lighten($color, 37%); } - &.public, &.running, &.list { + &.public, &.running, &.list, &.campaign { $color: $primary; color: lighten($color, 20%);; background: #e6f7ff; @@ -800,6 +800,11 @@ section.analytics { } /* Template form */ +.templates { + td .tag { + min-width: 100px; + } +} .template-modal { .template-modal-content { height: 95vh; diff --git a/frontend/src/views/Campaign.vue b/frontend/src/views/Campaign.vue index 369e69c..8cbcfa6 100644 --- a/frontend/src/views/Campaign.vue +++ b/frontend/src/views/Campaign.vue @@ -84,7 +84,10 @@ - + diff --git a/frontend/src/views/TemplateForm.vue b/frontend/src/views/TemplateForm.vue index 60b1224..ff62766 100644 --- a/frontend/src/views/TemplateForm.vue +++ b/frontend/src/views/TemplateForm.vue @@ -11,22 +11,44 @@

{{ $t('templates.newTemplate') }}