diff --git a/cmd/bounce.go b/cmd/bounce.go new file mode 100644 index 0000000..1ed87e8 --- /dev/null +++ b/cmd/bounce.go @@ -0,0 +1,251 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strconv" + "strings" + "time" + + "github.com/knadh/listmonk/internal/subimporter" + "github.com/knadh/listmonk/models" + "github.com/labstack/echo" + "github.com/lib/pq" +) + +type bouncesWrap struct { + Results []models.Bounce `json:"results"` + + Total int `json:"total"` + PerPage int `json:"per_page"` + Page int `json:"page"` +} + +// handleGetBounces handles retrieval of bounce records. +func handleGetBounces(c echo.Context) error { + var ( + app = c.Get("app").(*App) + pg = getPagination(c.QueryParams(), 50) + out bouncesWrap + + id, _ = strconv.Atoi(c.Param("id")) + campID, _ = strconv.Atoi(c.QueryParam("campaign_id")) + source = c.FormValue("source") + orderBy = c.FormValue("order_by") + order = c.FormValue("order") + ) + + // Fetch one list. + single := false + if id > 0 { + single = true + } + + // Sort params. + if !strSliceContains(orderBy, bounceQuerySortFields) { + orderBy = "created_at" + } + if order != sortAsc && order != sortDesc { + order = sortDesc + } + + stmt := fmt.Sprintf(app.queries.QueryBounces, orderBy, order) + if err := db.Select(&out.Results, stmt, id, campID, 0, source, pg.Offset, pg.Limit); err != nil { + app.log.Printf("error fetching bounces: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.bounce}", "error", pqErrMsg(err))) + } + if len(out.Results) == 0 { + out.Results = []models.Bounce{} + return c.JSON(http.StatusOK, okResp{out}) + } + + if single { + return c.JSON(http.StatusOK, okResp{out.Results[0]}) + } + + // Meta. + out.Total = out.Results[0].Total + out.Page = pg.Page + out.PerPage = pg.PerPage + + return c.JSON(http.StatusOK, okResp{out}) +} + +// handleGetSubscriberBounces retrieves a subscriber's bounce records. +func handleGetSubscriberBounces(c echo.Context) error { + var ( + app = c.Get("app").(*App) + subID = c.Param("id") + ) + + id, _ := strconv.ParseInt(subID, 10, 64) + if id < 1 { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) + } + + out := []models.Bounce{} + stmt := fmt.Sprintf(app.queries.QueryBounces, "created_at", "ASC") + if err := db.Select(&out, stmt, 0, 0, subID, "", 0, 1000); err != nil { + app.log.Printf("error fetching bounces: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("globals.messages.errorFetching", + "name", "{globals.terms.bounce}", "error", pqErrMsg(err))) + } + + return c.JSON(http.StatusOK, okResp{out}) +} + +// handleDeleteBounces handles bounce deletion, either a single one (ID in the URI), or a list. +func handleDeleteBounces(c echo.Context) error { + var ( + app = c.Get("app").(*App) + pID = c.Param("id") + all, _ = strconv.ParseBool(c.QueryParam("all")) + IDs = pq.Int64Array{} + ) + + // Is it an /:id call? + if pID != "" { + id, _ := strconv.ParseInt(pID, 10, 64) + if id < 1 { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) + } + IDs = append(IDs, id) + } else if !all { + // Multiple IDs. + i, err := parseStringIDs(c.Request().URL.Query()["id"]) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.invalidID", "error", err.Error())) + } + + if len(i) == 0 { + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.invalidID")) + } + IDs = i + } + + if _, err := app.queries.DeleteBounces.Exec(IDs); err != nil { + app.log.Printf("error deleting bounces: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("globals.messages.errorDeleting", + "name", "{globals.terms.bounce}", "error", pqErrMsg(err))) + } + + return c.JSON(http.StatusOK, okResp{true}) +} + +// handleBounceWebhook renders the HTML preview of a template. +func handleBounceWebhook(c echo.Context) error { + var ( + app = c.Get("app").(*App) + service = c.Param("service") + + bounces []models.Bounce + ) + + // Read the request body instead of using using c.Bind() to read to save the entire raw request as meta. + rawReq, err := ioutil.ReadAll(c.Request().Body) + if err != nil { + app.log.Printf("error reading ses notification body: %v", err) + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.internalError")) + } + + switch true { + // Native internal webhook. + case service == "": + var b models.Bounce + if err := json.Unmarshal(rawReq, &b); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidData")) + } + + if err := validateBounceFields(b, app); err != nil { + return err + } + + b.Email = strings.ToLower(b.Email) + + if len(b.Meta) == 0 { + b.Meta = json.RawMessage("{}") + } + + if b.CreatedAt.Year() == 0 { + b.CreatedAt = time.Now() + } + + bounces = append(bounces, b) + + // Amazon SES. + case service == "ses" && app.constants.BounceSESEnabled: + switch c.Request().Header.Get("X-Amz-Sns-Message-Type") { + // SNS webhook registration confirmation. Only after these are processed will the endpoint + // start getting bounce notifications. + case "SubscriptionConfirmation", "UnsubscribeConfirmation": + if err := app.bounce.SES.ProcessSubscription(rawReq); err != nil { + app.log.Printf("error processing SNS (SES) subscription: %v", err) + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) + } + break + + // Bounce notification. + case "Notification": + b, err := app.bounce.SES.ProcessBounce(rawReq) + if err != nil { + app.log.Printf("error processing SES notification: %v", err) + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) + } + bounces = append(bounces, b) + + default: + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) + } + + // SendGrid. + case service == "sendgrid" && app.constants.BounceSendgridEnabled: + var ( + sig = c.Request().Header.Get("X-Twilio-Email-Event-Webhook-Signature") + ts = c.Request().Header.Get("X-Twilio-Email-Event-Webhook-Timestamp") + ) + + // Sendgrid sends multiple bounces. + bs, err := app.bounce.Sendgrid.ProcessBounce(sig, ts, rawReq) + if err != nil { + app.log.Printf("error processing sendgrid notification: %v", err) + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) + } + bounces = append(bounces, bs...) + + default: + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("bounces.unknownService")) + } + + // Record bounces if any. + for _, b := range bounces { + if err := app.bounce.Record(b); err != nil { + app.log.Printf("error recording bounce: %v", err) + } + } + + return c.JSON(http.StatusOK, okResp{true}) +} + +func validateBounceFields(b models.Bounce, app *App) error { + if b.Email == "" && b.SubscriberUUID == "" { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) + } + + if b.Email != "" && !subimporter.IsEmail(b.Email) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidEmail")) + } + + if b.SubscriberUUID != "" && !reUUID.MatchString(b.SubscriberUUID) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidUUID")) + } + + return nil +} diff --git a/cmd/campaigns.go b/cmd/campaigns.go index c21d1b1..312f799 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -73,6 +73,7 @@ var ( regexFullTextQuery = regexp.MustCompile(`\s+`) campaignQuerySortFields = []string{"name", "status", "created_at", "updated_at"} + bounceQuerySortFields = []string{"email", "campaign_name", "source", "created_at"} ) // handleGetCampaigns handles retrieval of campaigns. diff --git a/cmd/handlers.go b/cmd/handlers.go index bcf6f50..ebf64c1 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -62,6 +62,8 @@ func registerHTTPHandlers(e *echo.Echo, app *App) { g.GET("/api/subscribers/:id", handleGetSubscriber) g.GET("/api/subscribers/:id/export", handleExportSubscriberData) + g.GET("/api/subscribers/:id/bounces", handleGetSubscriberBounces) + g.DELETE("/api/subscribers/:id/bounces", handleDeleteSubscriberBounces) g.POST("/api/subscribers", handleCreateSubscriber) g.PUT("/api/subscribers/:id", handleUpdateSubscriber) g.POST("/api/subscribers/:id/optin", handleSubscriberSendOptin) @@ -72,6 +74,10 @@ func registerHTTPHandlers(e *echo.Echo, app *App) { g.DELETE("/api/subscribers/:id", handleDeleteSubscribers) g.DELETE("/api/subscribers", handleDeleteSubscribers) + g.GET("/api/bounces", handleGetBounces) + g.DELETE("/api/bounces", handleDeleteBounces) + g.DELETE("/api/bounces/:id", handleDeleteBounces) + // Subscriber operations based on arbitrary SQL queries. // These aren't very REST-like. g.POST("/api/subscribers/query/delete", handleDeleteSubscribersByQuery) @@ -132,6 +138,14 @@ func registerHTTPHandlers(e *echo.Echo, app *App) { g.GET("/settings", handleIndexPage) g.GET("/settings/logs", handleIndexPage) + if app.constants.BounceWebhooksEnabled { + // Private authenticated bounce endpoint. + g.POST("/webhooks/bounce", handleBounceWebhook) + + // Public bounce endpoints for webservices like SES. + e.POST("/webhooks/service/:service", handleBounceWebhook) + } + // Public subscriber facing views. e.GET("/subscription/form", handleSubscriptionFormPage) e.POST("/subscription/form", handleSubscriptionForm) diff --git a/cmd/init.go b/cmd/init.go index 994e756..ff0eeb2 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -21,6 +21,8 @@ import ( "github.com/knadh/koanf/providers/confmap" "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/providers/posflag" + "github.com/knadh/listmonk/internal/bounce" + "github.com/knadh/listmonk/internal/bounce/mailbox" "github.com/knadh/listmonk/internal/i18n" "github.com/knadh/listmonk/internal/manager" "github.com/knadh/listmonk/internal/media" @@ -65,6 +67,10 @@ type constants struct { OptinURL string MessageURL string MediaProvider string + + BounceWebhooksEnabled bool + BounceSESEnabled bool + BounceSendgridEnabled bool } func initFlags() { @@ -296,6 +302,10 @@ func initConstants() *constants { // url.com/campaign/{campaign_uuid}/{subscriber_uuid}/px.png c.ViewTrackURL = fmt.Sprintf("%s/campaign/%%s/%%s/px.png", c.RootURL) + + c.BounceWebhooksEnabled = ko.Bool("bounce.webhooks_enabled") + c.BounceSESEnabled = ko.Bool("bounce.ses_enabled") + c.BounceSendgridEnabled = ko.Bool("bounce.sendgrid_enabled") return &c } @@ -344,8 +354,7 @@ func initCampaignManager(q *Queries, cs *constants, app *App) *manager.Manager { SlidingWindow: ko.Bool("app.message_sliding_window"), SlidingWindowDuration: ko.Duration("app.message_sliding_window_duration"), SlidingWindowRate: ko.Int("app.message_sliding_window_rate"), - }, newManagerDB(q), campNotifCB, app.i18n, lo) - + }, newManagerStore(q), campNotifCB, app.i18n, lo) } // initImporter initializes the bulk subscriber importer. @@ -495,6 +504,45 @@ func initNotifTemplates(path string, fs stuffbin.FileSystem, i *i18n.I18n, cs *c return tpl } +// initBounceManager initializes the bounce manager that scans mailboxes and listens to webhooks +// for incoming bounce events. +func initBounceManager(app *App) *bounce.Manager { + opt := bounce.Opt{ + BounceCount: ko.MustInt("bounce.count"), + BounceAction: ko.MustString("bounce.action"), + WebhooksEnabled: ko.Bool("bounce.webhooks_enabled"), + SESEnabled: ko.Bool("bounce.ses_enabled"), + SendgridEnabled: ko.Bool("bounce.sendgrid_enabled"), + SendgridKey: ko.String("bounce.sendgrid_key"), + } + + // For now, only one mailbox is supported. + for _, b := range ko.Slices("bounce.mailboxes") { + if !b.Bool("enabled") { + continue + } + + var boxOpt mailbox.Opt + if err := b.UnmarshalWithConf("", &boxOpt, koanf.UnmarshalConf{Tag: "json"}); err != nil { + lo.Fatalf("error reading bounce mailbox config: %v", err) + } + + opt.MailboxType = b.String("type") + opt.MailboxEnabled = true + opt.Mailbox = boxOpt + break + } + + b, err := bounce.New(opt, &bounce.Queries{ + RecordQuery: app.queries.RecordBounce, + }, app.log) + if err != nil { + lo.Fatalf("error initializing bounce manager: %v", err) + } + + return b +} + // initHTTPServer sets up and runs the app's main HTTP server and blocks forever. func initHTTPServer(app *App) *echo.Echo { // Initialize the HTTP server. diff --git a/cmd/lists.go b/cmd/lists.go index 5864763..d965b24 100644 --- a/cmd/lists.go +++ b/cmd/lists.go @@ -160,8 +160,7 @@ func handleUpdateList(c echo.Context) error { return handleGetLists(c) } -// handleDeleteLists handles deletion deletion, -// either a single one (ID in the URI), or a list. +// handleDeleteLists handles list deletion, either a single one (ID in the URI), or a list. func handleDeleteLists(c echo.Context) error { var ( app = c.Get("app").(*App) diff --git a/cmd/main.go b/cmd/main.go index ff1a1c7..37d02e2 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -17,6 +17,7 @@ import ( "github.com/jmoiron/sqlx" "github.com/knadh/koanf" "github.com/knadh/koanf/providers/env" + "github.com/knadh/listmonk/internal/bounce" "github.com/knadh/listmonk/internal/buflog" "github.com/knadh/listmonk/internal/i18n" "github.com/knadh/listmonk/internal/manager" @@ -42,6 +43,7 @@ type App struct { messengers map[string]messenger.Messenger media media.Store i18n *i18n.I18n + bounce *bounce.Manager notifTpls *template.Template log *log.Logger bufLog *buflog.BufLog @@ -168,6 +170,11 @@ func main() { app.importer = initImporter(app.queries, db, app) app.notifTpls = initNotifTemplates("/email-templates/*.html", fs, app.i18n, app.constants) + if ko.Bool("bounce.enabled") { + app.bounce = initBounceManager(app) + go app.bounce.Run() + } + // Initialize the default SMTP (`email`) messenger. app.messengers[emailMsgr] = initSMTPMessenger(app.manager) diff --git a/cmd/manager_db.go b/cmd/manager_store.go similarity index 71% rename from cmd/manager_db.go rename to cmd/manager_store.go index 7ed7d96..5ff5e55 100644 --- a/cmd/manager_db.go +++ b/cmd/manager_store.go @@ -12,7 +12,7 @@ type runnerDB struct { queries *Queries } -func newManagerDB(q *Queries) *runnerDB { +func newManagerStore(q *Queries) *runnerDB { return &runnerDB{ queries: q, } @@ -64,3 +64,31 @@ func (r *runnerDB) CreateLink(url string) (string, error) { return out, nil } + +// RecordBounce records a bounce event and returns the bounce count. +func (r *runnerDB) RecordBounce(b models.Bounce) (int64, int, error) { + var res = struct { + SubscriberID int64 `db:"subscriber_id"` + Num int `db:"num"` + }{} + + err := r.queries.UpdateCampaignStatus.Select(&res, + b.SubscriberUUID, + b.Email, + b.CampaignUUID, + b.Type, + b.Source, + b.Meta) + + return res.SubscriberID, res.Num, err +} + +func (r *runnerDB) BlocklistSubscriber(id int64) error { + _, err := r.queries.BlocklistSubscribers.Exec(pq.Int64Array{id}) + return err +} + +func (r *runnerDB) DeleteSubscriber(id int64) error { + _, err := r.queries.DeleteSubscribers.Exec(pq.Int64Array{id}) + return err +} diff --git a/cmd/queries.go b/cmd/queries.go index e6f692f..3fdded4 100644 --- a/cmd/queries.go +++ b/cmd/queries.go @@ -83,6 +83,10 @@ type Queries struct { UpdateSettings *sqlx.Stmt `query:"update-settings"` // GetStats *sqlx.Stmt `query:"get-stats"` + RecordBounce *sqlx.Stmt `query:"record-bounce"` + QueryBounces string `query:"query-bounces"` + DeleteBounces *sqlx.Stmt `query:"delete-bounces"` + DeleteBouncesBySubscriber *sqlx.Stmt `query:"delete-bounces-by-subscriber"` } // dbConf contains database config required for connecting to a DB. diff --git a/cmd/settings.go b/cmd/settings.go index 72e9412..ae48705 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -80,6 +80,28 @@ type settings struct { Timeout string `json:"timeout"` MaxMsgRetries int `json:"max_msg_retries"` } `json:"messengers"` + + BounceEnabled bool `json:"bounce.enabled"` + BounceEnableWebhooks bool `json:"bounce.webhooks_enabled"` + BounceCount int `json:"bounce.count"` + BounceAction string `json:"bounce.action"` + SESEnabled bool `json:"bounce.ses_enabled"` + SendgridEnabled bool `json:"bounce.sendgrid_enabled"` + SendgridKey string `json:"bounce.sendgrid_key"` + BounceBoxes []struct { + UUID string `json:"uuid"` + Enabled bool `json:"enabled"` + Type string `json:"type"` + Host string `json:"host"` + Port int `json:"port"` + AuthProtocol string `json:"auth_protocol"` + ReturnPath string `json:"return_path"` + Username string `json:"username"` + Password string `json:"password,omitempty"` + TLSEnabled bool `json:"tls_enabled"` + TLSSkipVerify bool `json:"tls_skip_verify"` + ScanInterval string `json:"scan_interval"` + } `json:"bounce.mailboxes"` } var ( @@ -99,10 +121,14 @@ func handleGetSettings(c echo.Context) error { for i := 0; i < len(s.SMTP); i++ { s.SMTP[i].Password = "" } + for i := 0; i < len(s.BounceBoxes); i++ { + s.BounceBoxes[i].Password = "" + } for i := 0; i < len(s.Messengers); i++ { s.Messengers[i].Password = "" } s.UploadS3AwsSecretAccessKey = "" + s.SendgridKey = "" return c.JSON(http.StatusOK, okResp{s}) } @@ -154,6 +180,31 @@ func handleUpdateSettings(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("settings.errorNoSMTP")) } + // Bounce boxes. + for i, s := range set.BounceBoxes { + // Assign a UUID. The frontend only sends a password when the user explictly + // changes the password. In other cases, the existing password in the DB + // is copied while updating the settings and the UUID is used to match + // the incoming array of blocks with the array in the DB. + if s.UUID == "" { + set.BounceBoxes[i].UUID = uuid.Must(uuid.NewV4()).String() + } + + if d, _ := time.ParseDuration(s.ScanInterval); d.Minutes() < 1 { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("settings.bounces.invalidScanInterval")) + } + + // If there's no password coming in from the frontend, copy the existing + // password by matching the UUID. + if s.Password == "" { + for _, c := range cur.BounceBoxes { + if s.UUID == c.UUID { + set.BounceBoxes[i].Password = c.Password + } + } + } + } + // Validate and sanitize postback Messenger names. Duplicates are disallowed // and "email" is a reserved name. names := map[string]bool{emailMsgr: true} @@ -189,6 +240,9 @@ func handleUpdateSettings(c echo.Context) error { if set.UploadS3AwsSecretAccessKey == "" { set.UploadS3AwsSecretAccessKey = cur.UploadS3AwsSecretAccessKey } + if set.SendgridKey == "" { + set.SendgridKey = cur.SendgridKey + } // Marshal settings. b, err := json.Marshal(set) diff --git a/cmd/subscribers.go b/cmd/subscribers.go index 56462df..a4a6288 100644 --- a/cmd/subscribers.go +++ b/cmd/subscribers.go @@ -614,6 +614,28 @@ func handleManageSubscriberListsByQuery(c echo.Context) error { return c.JSON(http.StatusOK, okResp{true}) } +// handleDeleteSubscriberBounces deletes all the bounces on a subscriber. +func handleDeleteSubscriberBounces(c echo.Context) error { + var ( + app = c.Get("app").(*App) + pID = c.Param("id") + ) + + id, _ := strconv.ParseInt(pID, 10, 64) + if id < 1 { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) + } + + if _, err := app.queries.DeleteBouncesBySubscriber.Exec(id, nil); err != nil { + app.log.Printf("error deleting bounces: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("globals.messages.errorDeleting", + "name", "{globals.terms.bounces}", "error", pqErrMsg(err))) + } + + return c.JSON(http.StatusOK, okResp{true}) +} + // handleExportSubscriberData pulls the subscriber's profile, // list subscriptions, campaign views and clicks and produces // a JSON report. This is a privacy feature and depends on the diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 72e6b8f..a27bc91 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -30,6 +30,7 @@ var migList = []migFunc{ {"v0.8.0", migrations.V0_8_0}, {"v0.9.0", migrations.V0_9_0}, {"v1.0.0", migrations.V1_0_0}, + {"v2.0.0", migrations.V2_0_0}, } // upgrade upgrades the database to the current version by running SQL migration files diff --git a/frontend/fontello/config.json b/frontend/fontello/config.json index 5e8f936..218347a 100644 --- a/frontend/fontello/config.json +++ b/frontend/fontello/config.json @@ -510,6 +510,34 @@ "magnify" ] }, + { + "uid": "e97fad4c93444c9b81151c2aa4086e13", + "css": "chart-bar", + "code": 59428, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M916 875H84V125H166V791H250V416H416V791H500V250H666V791H750V584H916V875Z", + "width": 1000 + }, + "search": [ + "chart-bar" + ] + }, + { + "uid": "61e03b48670cd93477e233e0d6bb3f1c", + "css": "email-bounce", + "code": 59429, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M916 834H750V959L541 771.5 750 584V709H916V834ZM834 166H166Q132.8 166 108.4 190.4T84 250V750Q84 785.2 108.4 809.6T166 834H459V750H166V334L500 541 834 334V625H916V250Q916 214.8 891.6 190.4T834 166ZM500 459L166 250H834Z", + "width": 1000 + }, + "search": [ + "email-bounce" + ] + }, { "uid": "f4ad3f6d071a0bfb3a8452b514ed0892", "css": "vector-square", @@ -4570,20 +4598,6 @@ "chart-areaspline" ] }, - { - "uid": "e97fad4c93444c9b81151c2aa4086e13", - "css": "chart-bar", - "code": 983336, - "src": "custom_icons", - "selected": false, - "svg": { - "path": "M916 875H84V125H166V791H250V416H416V791H500V250H666V791H750V584H916V875Z", - "width": 1000 - }, - "search": [ - "chart-bar" - ] - }, { "uid": "298bc9b464d2b4e5cad91cd3d419747f", "css": "chart-histogram", @@ -60444,20 +60458,6 @@ "email-receive" ] }, - { - "uid": "61e03b48670cd93477e233e0d6bb3f1c", - "css": "email-receive-outline", - "code": 987355, - "src": "custom_icons", - "selected": false, - "svg": { - "path": "M916 834H750V959L541 771.5 750 584V709H916V834ZM834 166H166Q132.8 166 108.4 190.4T84 250V750Q84 785.2 108.4 809.6T166 834H459V750H166V334L500 541 834 334V625H916V250Q916 214.8 891.6 190.4T834 166ZM500 459L166 250H834Z", - "width": 1000 - }, - "search": [ - "email-receive-outline" - ] - }, { "uid": "b52d1e4a907f8f98e038e8997079e456", "css": "email-send", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3297aa5..485d26a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -55,6 +55,10 @@ + + http.delete(`/api/lists/${id}`, export const getSubscribers = async (params) => http.get('/api/subscribers', { params, loading: models.subscribers, store: models.subscribers }); +export const getSubscriber = async (id) => http.get(`/api/subscribers/${id}`, + { loading: models.subscribers }); + +export const getSubscriberBounces = async (id) => http.get(`/api/subscribers/${id}/bounces`, + { loading: models.bounces }); + +export const deleteSubscriberBounces = async (id) => http.delete(`/api/subscribers/${id}/bounces`, + { loading: models.bounces }); + +export const deleteBounce = async (id) => http.delete(`/api/bounces/${id}`, + { loading: models.bounces }); + +export const deleteBounces = async (params) => http.delete('/api/bounces', + { params, loading: models.bounces }); + export const createSubscriber = (data) => http.post('/api/subscribers', data, { loading: models.subscribers }); @@ -148,6 +163,10 @@ export const getImportLogs = async () => http.get('/api/import/subscribers/logs' export const stopImport = () => http.delete('/api/import/subscribers'); +// Bounces. +export const getBounces = async (params) => http.get('/api/bounces', + { params, loading: models.bounces }); + // Campaigns. export const getCampaigns = async (params) => http.get('/api/campaigns', { params, loading: models.campaigns, store: models.campaigns }); diff --git a/frontend/src/assets/icons/fontello.css b/frontend/src/assets/icons/fontello.css index 3d6cf7e..5d34a5d 100644 --- a/frontend/src/assets/icons/fontello.css +++ b/frontend/src/assets/icons/fontello.css @@ -5,37 +5,37 @@ font-style: normal; } - [class^="mdi-"]:before, [class*=" mdi-"]:before { +[class^="mdi-"]:before, [class*=" mdi-"]:before { font-family: "fontello"; font-style: normal; font-weight: normal; speak: never; - + display: inline-block; text-decoration: inherit; width: 1em; margin-right: .2em; text-align: center; /* opacity: .8; */ - + /* For safety - reset parent styles, that can break glyph codes*/ font-variant: normal; text-transform: none; - + /* fix buttons height, for twitter bootstrap */ line-height: 1em; - + /* Animation center compensation - margins should be symmetric */ /* remove if not needed */ margin-left: .2em; - + /* you can be more comfortable with increased icons size */ /* font-size: 120%; */ - + /* Font smoothing. That was taken from TWBS */ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - + /* Uncomment for 3D effect */ /* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */ } @@ -76,3 +76,5 @@ .mdi-arrow-down:before { content: '\e821'; } /* '' */ .mdi-cancel:before { content: '\e822'; } /* '' */ .mdi-magnify:before { content: '\e823'; } /* '' */ +.mdi-chart-bar:before { content: '\e824'; } /* '' */ +.mdi-email-bounce:before { content: '\e825'; } /* '' */ diff --git a/frontend/src/assets/icons/fontello.woff2 b/frontend/src/assets/icons/fontello.woff2 index fb07f15..af01501 100644 Binary files a/frontend/src/assets/icons/fontello.woff2 and b/frontend/src/assets/icons/fontello.woff2 differ diff --git a/frontend/src/assets/style.scss b/frontend/src/assets/style.scss index ec1528e..8d5bd00 100644 --- a/frontend/src/assets/style.scss +++ b/frontend/src/assets/style.scss @@ -92,7 +92,17 @@ section { } .box { - box-shadow: 0 0 2px $grey-lighter; + background: $white-bis; + box-shadow: 2px 2px 5px $white-ter; + border: 1px solid $grey-lightest; + + hr { + background-color: #efefef; + } +} + +.page-header { + min-height: 60px; } /* Two column sidebar+body layout */ @@ -323,7 +333,7 @@ section { /* Tabs */ .b-tabs .tab-content { - padding-top: 2rem; + padding-top: 3rem; } /* Tags */ @@ -449,6 +459,12 @@ section.lists { } } +.bounces { + pre { + padding: 5px 10px; + } +} + /* Import page */ section.import { .delimiter input { diff --git a/frontend/src/constants.js b/frontend/src/constants.js index 6f7bdec..4d23c82 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -7,6 +7,7 @@ export const models = Object.freeze({ campaigns: 'campaigns', templates: 'templates', media: 'media', + bounces: 'bounces', settings: 'settings', logs: 'logs', }); diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index e19437a..724a282 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -35,6 +35,12 @@ const routes = [ meta: { title: 'Import subscribers', group: 'subscribers' }, component: () => import(/* webpackChunkName: "main" */ '../views/Import.vue'), }, + { + path: '/subscribers/bounces', + name: 'bounces', + meta: { title: 'Bounces', group: 'subscribers' }, + component: () => import(/* webpackChunkName: "main" */ '../views/Bounces.vue'), + }, { path: '/subscribers/lists/:listID', name: 'subscribers_list', diff --git a/frontend/src/views/Bounces.vue b/frontend/src/views/Bounces.vue new file mode 100644 index 0000000..5ed039f --- /dev/null +++ b/frontend/src/views/Bounces.vue @@ -0,0 +1,196 @@ + + + diff --git a/frontend/src/views/Campaigns.vue b/frontend/src/views/Campaigns.vue index 52cdec2..47575f8 100644 --- a/frontend/src/views/Campaigns.vue +++ b/frontend/src/views/Campaigns.vue @@ -117,6 +117,12 @@ {{ stats.sent }} / {{ stats.toSend }}

+

+ + + {{ props.row.bounces }} + +

diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue index 5697f8f..7c64b60 100644 --- a/frontend/src/views/Settings.vue +++ b/frontend/src/views/Settings.vue @@ -433,6 +433,173 @@ + +

+
+ + + +
+
+ + + +
+
+ + + + + + +
+
+ +
+ + +

+ {{ $t('globals.buttons.learnMore') }} → +

+
+
+
+
+ + + +
+
+
+
+ + + +
+
+ + + +
+
+
+
+ + + + + + + + +
@@ -583,6 +750,10 @@ export default Vue.extend({ this.form.smtp.splice(i, 1); }, + removeBounceBox(i) { + this.form['bounce.mailboxes'].splice(i, 1); + }, + showSMTPHeaders(i) { const s = this.form.smtp[i]; s.showHeaders = true; @@ -615,7 +786,7 @@ export default Vue.extend({ onSubmit() { const form = JSON.parse(JSON.stringify(this.form)); - // De-serialize custom e-mail headers. + // SMTP boxes. for (let i = 0; i < form.smtp.length; i += 1) { // If it's the dummy UI password placeholder, ignore it. if (form.smtp[i].password === dummyPassword) { @@ -629,10 +800,22 @@ export default Vue.extend({ } } + // Bounces boxes. + for (let i = 0; i < form['bounce.mailboxes'].length; i += 1) { + // If it's the dummy UI password placeholder, ignore it. + if (form['bounce.mailboxes'][i].password === dummyPassword) { + form['bounce.mailboxes'][i].password = ''; + } + } + if (form['upload.s3.aws_secret_access_key'] === dummyPassword) { form['upload.s3.aws_secret_access_key'] = ''; } + if (form['bounce.sendgrid_key'] === dummyPassword) { + form['bounce.sendgrid_key'] = ''; + } + for (let i = 0; i < form.messengers.length; i += 1) { // If it's the dummy UI password placeholder, ignore it. if (form.messengers[i].password === dummyPassword) { @@ -680,6 +863,12 @@ export default Vue.extend({ d.smtp[i].password = dummyPassword; } + for (let i = 0; i < d['bounce.mailboxes'].length; i += 1) { + // The backend doesn't send passwords, so add a dummy so that + // the password looks filled on the UI. + d['bounce.mailboxes'][i].password = dummyPassword; + } + for (let i = 0; i < d.messengers.length; i += 1) { // The backend doesn't send passwords, so add a dummy so that it // the password looks filled on the UI. @@ -689,6 +878,7 @@ export default Vue.extend({ if (d['upload.provider'] === 's3') { d['upload.s3.aws_secret_access_key'] = dummyPassword; } + d['bounce.sendgrid_key'] = dummyPassword; this.form = d; this.formCopy = JSON.stringify(d); diff --git a/frontend/src/views/SubscriberForm.vue b/frontend/src/views/SubscriberForm.vue index 62a3d89..7cd3241 100644 --- a/frontend/src/views/SubscriberForm.vue +++ b/frontend/src/views/SubscriberForm.vue @@ -2,7 +2,6 @@