diff --git a/cmd/admin.go b/cmd/admin.go index 767fd55..e2e80af 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "sort" "syscall" "time" @@ -29,11 +30,22 @@ func handleGetConfigScript(c echo.Context) error { out = configScript{ RootURL: app.constants.RootURL, FromEmail: app.constants.FromEmail, - Messengers: app.manager.GetMessengerNames(), MediaProvider: app.constants.MediaProvider, } ) + // Sort messenger names with `email` always as the first item. + var names []string + for name := range app.messengers { + if name == emailMsgr { + continue + } + names = append(names, name) + } + sort.Strings(names) + out.Messengers = append(out.Messengers, emailMsgr) + out.Messengers = append(out.Messengers, names...) + app.Lock() out.NeedsRestart = app.needsRestart out.Update = app.update diff --git a/cmd/campaigns.go b/cmd/campaigns.go index e6b7fc5..fac1879 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -220,11 +220,6 @@ func handleCreateCampaign(c echo.Context) error { o = c } - if !app.manager.HasMessenger(o.MessengerID) { - return echo.NewHTTPError(http.StatusBadRequest, - fmt.Sprintf("Unknown messenger %s", o.MessengerID)) - } - uu, err := uuid.NewV4() if err != nil { app.log.Printf("error generating UUID: %v", err) @@ -243,7 +238,7 @@ func handleCreateCampaign(c echo.Context) error { o.ContentType, o.SendAt, pq.StringArray(normalizeTags(o.Tags)), - "email", + o.Messenger, o.TemplateID, o.ListIDs, ); err != nil { @@ -312,6 +307,7 @@ func handleUpdateCampaign(c echo.Context) error { o.SendAt, o.SendLater, pq.StringArray(normalizeTags(o.Tags)), + o.Messenger, o.TemplateID, o.ListIDs) if err != nil { @@ -492,6 +488,7 @@ func handleTestCampaign(c echo.Context) error { if err := c.Bind(&req); err != nil { return err } + // Validate. if c, err := validateCampaignFields(req, app); err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) @@ -532,6 +529,9 @@ func handleTestCampaign(c echo.Context) error { camp.Subject = req.Subject camp.FromEmail = req.FromEmail camp.Body = req.Body + camp.Messenger = req.Messenger + camp.ContentType = req.ContentType + camp.TemplateID = req.TemplateID // Send the test messages. for _, s := range subs { @@ -560,11 +560,14 @@ func sendTestMessage(sub models.Subscriber, camp *models.Campaign, app *App) err fmt.Sprintf("Error rendering message: %v", err)) } - return app.messenger.Push(messenger.Message{ - From: camp.FromEmail, - To: []string{sub.Email}, - Subject: m.Subject(), - Body: m.Body(), + return app.messengers[camp.Messenger].Push(messenger.Message{ + From: camp.FromEmail, + To: []string{sub.Email}, + Subject: m.Subject(), + ContentType: camp.ContentType, + Body: m.Body(), + Subscriber: sub, + Campaign: camp, }) } @@ -600,9 +603,13 @@ func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) { return c, errors.New("no lists selected") } + if !app.manager.HasMessenger(c.Messenger) { + return c, fmt.Errorf("unknown messenger %s", c.Messenger) + } + camp := models.Campaign{Body: c.Body, TemplateBody: tplTag} if err := c.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil { - return c, fmt.Errorf("Error compiling campaign body: %v", err) + return c, fmt.Errorf("error compiling campaign body: %v", err) } return c, nil diff --git a/cmd/init.go b/cmd/init.go index ca358c0..4228f5d 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -25,6 +25,8 @@ import ( "github.com/knadh/listmonk/internal/media/providers/filesystem" "github.com/knadh/listmonk/internal/media/providers/s3" "github.com/knadh/listmonk/internal/messenger" + "github.com/knadh/listmonk/internal/messenger/email" + "github.com/knadh/listmonk/internal/messenger/postback" "github.com/knadh/listmonk/internal/subimporter" "github.com/knadh/stuffbin" "github.com/labstack/echo" @@ -290,11 +292,11 @@ func initImporter(q *Queries, db *sqlx.DB, app *App) *subimporter.Importer { }, db.DB) } -// initMessengers initializes various messenger backends. -func initMessengers(m *manager.Manager) messenger.Messenger { +// initSMTPMessenger initializes the SMTP messenger. +func initSMTPMessenger(m *manager.Manager) messenger.Messenger { var ( mapKeys = ko.MapKeys("smtp") - servers = make([]messenger.Server, 0, len(mapKeys)) + servers = make([]email.Server, 0, len(mapKeys)) ) items := ko.Slices("smtp") @@ -302,37 +304,71 @@ func initMessengers(m *manager.Manager) messenger.Messenger { lo.Fatalf("no SMTP servers found in config") } - // Load the default SMTP messengers. + // Load the config for multipme SMTP servers. for _, item := range items { if !item.Bool("enabled") { continue } // Read the SMTP config. - var s messenger.Server + var s email.Server if err := item.UnmarshalWithConf("", &s, koanf.UnmarshalConf{Tag: "json"}); err != nil { - lo.Fatalf("error loading SMTP: %v", err) + lo.Fatalf("error reading SMTP config: %v", err) } servers = append(servers, s) - lo.Printf("loaded SMTP: %s@%s", item.String("username"), item.String("host")) + lo.Printf("loaded email (SMTP) messenger: %s@%s", + item.String("username"), item.String("host")) } if len(servers) == 0 { lo.Fatalf("no SMTP servers enabled in settings") } - // Initialize the default e-mail messenger. - msgr, err := messenger.NewEmailer(servers...) + // Initialize the e-mail messenger with multiple SMTP servers. + msgr, err := email.New(servers...) if err != nil { lo.Fatalf("error loading e-mail messenger: %v", err) } - if err := m.AddMessenger(msgr); err != nil { - lo.Printf("error registering messenger %s", err) - } return msgr } +// initPostbackMessengers initializes and returns all the enabled +// HTTP postback messenger backends. +func initPostbackMessengers(m *manager.Manager) []messenger.Messenger { + items := ko.Slices("messengers") + if len(items) == 0 { + return nil + } + + var out []messenger.Messenger + for _, item := range items { + if !item.Bool("enabled") { + continue + } + + // Read the Postback server config. + var ( + name = item.String("name") + o postback.Options + ) + if err := item.UnmarshalWithConf("", &o, koanf.UnmarshalConf{Tag: "json"}); err != nil { + lo.Fatalf("error reading Postback config: %v", err) + } + + // Initialize the Messenger. + p, err := postback.New(o) + if err != nil { + lo.Fatalf("error initializing Postback messenger %s: %v", name, err) + } + out = append(out, p) + + lo.Printf("loaded Postback messenger: %s", name) + } + + return out +} + // initMediaStore initializes Upload manager with a custom backend. func initMediaStore() media.Store { switch provider := ko.String("upload.provider"); provider { diff --git a/cmd/install.go b/cmd/install.go index de7e742..d097895 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -123,7 +123,7 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt bool) { "richtext", nil, pq.StringArray{"test-campaign"}, - "email", + emailMsgr, 1, pq.Int64Array{1}, ); err != nil { diff --git a/cmd/main.go b/cmd/main.go index c16be5b..048d33a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -22,19 +22,23 @@ import ( "github.com/knadh/stuffbin" ) +const ( + emailMsgr = "email" +) + // App contains the "global" components that are // passed around, especially through HTTP handlers. type App struct { - fs stuffbin.FileSystem - db *sqlx.DB - queries *Queries - constants *constants - manager *manager.Manager - importer *subimporter.Importer - messenger messenger.Messenger - media media.Store - notifTpls *template.Template - log *log.Logger + fs stuffbin.FileSystem + db *sqlx.DB + queries *Queries + constants *constants + manager *manager.Manager + importer *subimporter.Importer + messengers map[string]messenger.Messenger + media media.Store + notifTpls *template.Template + log *log.Logger // Channel for passing reload signals. sigChan chan os.Signal @@ -122,18 +126,31 @@ func main() { // Initialize the main app controller that wraps all of the app's // components. This is passed around HTTP handlers. app := &App{ - fs: fs, - db: db, - constants: initConstants(), - media: initMediaStore(), - log: lo, + fs: fs, + db: db, + constants: initConstants(), + media: initMediaStore(), + messengers: make(map[string]messenger.Messenger), + log: lo, } _, app.queries = initQueries(queryFilePath, db, fs, true) app.manager = initCampaignManager(app.queries, app.constants, app) app.importer = initImporter(app.queries, db, app) - app.messenger = initMessengers(app.manager) app.notifTpls = initNotifTemplates("/email-templates/*.html", fs, app.constants) + // Initialize the default SMTP (`email`) messenger. + app.messengers[emailMsgr] = initSMTPMessenger(app.manager) + + // Initialize any additional postback messengers. + for _, m := range initPostbackMessengers(app.manager) { + app.messengers[m.Name()] = m + } + + // Attach all messengers to the campaign manager. + for _, m := range app.messengers { + app.manager.AddMessenger(m) + } + // Start the campaign workers. The campaign batches (fetch from DB, push out // messages) get processed at the specified interval. go app.manager.Run(time.Second * 5) @@ -164,7 +181,9 @@ func main() { app.db.DB.Close() // Close the messenger pool. - app.messenger.Close() + for _, m := range app.messengers { + m.Close() + } // Signal the close. closerWait <- true diff --git a/cmd/notifications.go b/cmd/notifications.go index a74d041..3fd0406 100644 --- a/cmd/notifications.go +++ b/cmd/notifications.go @@ -28,14 +28,13 @@ func (app *App) sendNotification(toEmails []string, subject, tplName string, dat return err } - err := app.manager.PushMessage(manager.Message{ - From: app.constants.FromEmail, - To: toEmails, - Subject: subject, - Body: b.Bytes(), - Messenger: "email", - }) - if err != nil { + m := manager.Message{} + m.From = app.constants.FromEmail + m.To = toEmails + m.Subject = subject + m.Body = b.Bytes() + m.Messenger = emailMsgr + if err := app.manager.PushMessage(m); err != nil { app.log.Printf("error sending admin notification (%s): %v", subject, err) return err } diff --git a/cmd/public.go b/cmd/public.go index 6c753fa..c04b4d2 100644 --- a/cmd/public.go +++ b/cmd/public.go @@ -367,7 +367,7 @@ func handleSelfExportSubscriberData(c echo.Context) error { // Send the data as a JSON attachment to the subscriber. const fname = "data.json" - if err := app.messenger.Push(messenger.Message{ + if err := app.messengers[emailMsgr].Push(messenger.Message{ From: app.constants.FromEmail, To: []string{data.Email}, Subject: "Your data", diff --git a/cmd/settings.go b/cmd/settings.go index 24ad4d8..c4035dd 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "net/http" + "regexp" + "strings" "syscall" "time" @@ -22,14 +24,24 @@ type settings struct { AppMaxSendErrors int `json:"app.max_send_errors"` AppMessageRate int `json:"app.message_rate"` - Messengers []interface{} `json:"messengers"` - PrivacyUnsubHeader bool `json:"privacy.unsubscribe_header"` PrivacyAllowBlocklist bool `json:"privacy.allow_blocklist"` PrivacyAllowExport bool `json:"privacy.allow_export"` PrivacyAllowWipe bool `json:"privacy.allow_wipe"` PrivacyExportable []string `json:"privacy.exportable"` + UploadProvider string `json:"upload.provider"` + UploadFilesystemUploadPath string `json:"upload.filesystem.upload_path"` + UploadFilesystemUploadURI string `json:"upload.filesystem.upload_uri"` + UploadS3AwsAccessKeyID string `json:"upload.s3.aws_access_key_id"` + UploadS3AwsDefaultRegion string `json:"upload.s3.aws_default_region"` + UploadS3AwsSecretAccessKey string `json:"upload.s3.aws_secret_access_key,omitempty"` + UploadS3Bucket string `json:"upload.s3.bucket"` + UploadS3BucketDomain string `json:"upload.s3.bucket_domain"` + UploadS3BucketPath string `json:"upload.s3.bucket_path"` + UploadS3BucketType string `json:"upload.s3.bucket_type"` + UploadS3Expiry string `json:"upload.s3.expiry"` + SMTP []struct { Enabled bool `json:"enabled"` Host string `json:"host"` @@ -47,21 +59,22 @@ type settings struct { TLSSkipVerify bool `json:"tls_skip_verify"` } `json:"smtp"` - UploadProvider string `json:"upload.provider"` - - UploadFilesystemUploadPath string `json:"upload.filesystem.upload_path"` - UploadFilesystemUploadURI string `json:"upload.filesystem.upload_uri"` - - UploadS3AwsAccessKeyID string `json:"upload.s3.aws_access_key_id"` - UploadS3AwsDefaultRegion string `json:"upload.s3.aws_default_region"` - UploadS3AwsSecretAccessKey string `json:"upload.s3.aws_secret_access_key,omitempty"` - UploadS3Bucket string `json:"upload.s3.bucket"` - UploadS3BucketDomain string `json:"upload.s3.bucket_domain"` - UploadS3BucketPath string `json:"upload.s3.bucket_path"` - UploadS3BucketType string `json:"upload.s3.bucket_type"` - UploadS3Expiry string `json:"upload.s3.expiry"` + Messengers []struct { + Enabled bool `json:"enabled"` + Name string `json:"name"` + RootURL string `json:"root_url"` + Username string `json:"username"` + Password string `json:"password,omitempty"` + MaxConns int `json:"max_conns"` + Timeout string `json:"timeout"` + MaxMsgRetries int `json:"max_msg_retries"` + } `json:"messengers"` } +var ( + reAlphaNum = regexp.MustCompile(`[^a-z0-9\-]`) +) + // handleGetSettings returns settings from the DB. func handleGetSettings(c echo.Context) error { app := c.Get("app").(*App) @@ -75,6 +88,9 @@ func handleGetSettings(c echo.Context) error { for i := 0; i < len(s.SMTP); i++ { s.SMTP[i].Password = "" } + for i := 0; i < len(s.Messengers); i++ { + s.Messengers[i].Password = "" + } s.UploadS3AwsSecretAccessKey = "" return c.JSON(http.StatusOK, okResp{s}) @@ -111,13 +127,43 @@ func handleUpdateSettings(c echo.Context) error { if len(cur.SMTP) > i && set.SMTP[i].Host == cur.SMTP[i].Host && set.SMTP[i].Username == cur.SMTP[i].Username { + // Copy the existing password as password's needn't be + // sent from the frontend for updating entries. set.SMTP[i].Password = cur.SMTP[i].Password } } } if !has { return echo.NewHTTPError(http.StatusBadRequest, - "Minimum one SMTP block should be enabled.") + "At least one SMTP block should be enabled.") + } + + // Validate and sanitize postback Messenger names. Duplicates are disallowed + // and "email" is a reserved name. + names := map[string]bool{emailMsgr: true} + + for i := range set.Messengers { + if set.Messengers[i].Password == "" { + if len(cur.Messengers) > i && + set.Messengers[i].RootURL == cur.Messengers[i].RootURL && + set.Messengers[i].Username == cur.Messengers[i].Username { + // Copy the existing password as password's needn't be + // sent from the frontend for updating entries. + set.Messengers[i].Password = cur.Messengers[i].Password + } + } + + name := reAlphaNum.ReplaceAllString(strings.ToLower(set.Messengers[i].Name), "") + if _, ok := names[name]; ok { + return echo.NewHTTPError(http.StatusBadRequest, + fmt.Sprintf("Duplicate messenger name `%s`.", name)) + } + if len(name) == 0 { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid messenger name.") + } + + set.Messengers[i].Name = name + names[name] = true } // S3 password? diff --git a/frontend/public/index.html b/frontend/public/index.html index 6aa1ea1..8aa84f0 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -7,7 +7,7 @@