From 1ae98699e7fe5be8c0f4ede5042a88e38e90214a Mon Sep 17 00:00:00 2001 From: Kailash Nadh Date: Mon, 24 May 2021 22:41:48 +0530 Subject: [PATCH] Add support for bounce processing. - Blocklist or unsubscribe subscribers based on a bounce threshold - Add /bounces UI for viewing bounces and in the subscriber view - Add settings UI for managing bounce settings - Add support for scanning POP3 bounce mailboxes - Add a generic webhook for posting custom bounces at /webhooks/bounce - Add SES bounce webhook support at /webhooks/services/ses - Add Sendgrid bounce webhook support at /webhooks/services/sendgrid --- cmd/bounce.go | 251 ++++++++++++++++++++++ cmd/campaigns.go | 1 + cmd/handlers.go | 14 ++ cmd/init.go | 52 ++++- cmd/lists.go | 3 +- cmd/main.go | 7 + cmd/{manager_db.go => manager_store.go} | 30 ++- cmd/queries.go | 4 + cmd/settings.go | 54 +++++ cmd/subscribers.go | 22 ++ cmd/upgrade.go | 1 + frontend/fontello/config.json | 56 ++--- frontend/src/App.vue | 4 + frontend/src/api/index.js | 19 ++ frontend/src/assets/icons/fontello.css | 18 +- frontend/src/assets/icons/fontello.woff2 | Bin 6824 -> 7288 bytes frontend/src/assets/style.scss | 20 +- frontend/src/constants.js | 1 + frontend/src/router/index.js | 6 + frontend/src/views/Bounces.vue | 196 +++++++++++++++++ frontend/src/views/Campaigns.vue | 6 + frontend/src/views/Settings.vue | 192 ++++++++++++++++- frontend/src/views/SubscriberForm.vue | 117 +++++++++-- frontend/src/views/Subscribers.vue | 19 +- go.mod | 1 + go.sum | 9 +- i18n/en.json | 46 +++- internal/bounce/bounce.go | 148 +++++++++++++ internal/bounce/gmail.bounce | 257 +++++++++++++++++++++++ internal/bounce/mailbox/opt.go | 29 +++ internal/bounce/mailbox/pop.go | 119 +++++++++++ internal/bounce/proton.bounce | 152 ++++++++++++++ internal/bounce/webhooks/sendgrid.go | 104 +++++++++ internal/bounce/webhooks/ses.go | 249 ++++++++++++++++++++++ internal/manager/manager.go | 98 +++++++-- internal/migrations/v2.0.0.go | 43 ++++ models/models.go | 31 +++ queries.sql | 71 +++++++ schema.sql | 27 ++- 39 files changed, 2386 insertions(+), 91 deletions(-) create mode 100644 cmd/bounce.go rename cmd/{manager_db.go => manager_store.go} (71%) create mode 100644 frontend/src/views/Bounces.vue create mode 100644 internal/bounce/bounce.go create mode 100644 internal/bounce/gmail.bounce create mode 100644 internal/bounce/mailbox/opt.go create mode 100644 internal/bounce/mailbox/pop.go create mode 100644 internal/bounce/proton.bounce create mode 100644 internal/bounce/webhooks/sendgrid.go create mode 100644 internal/bounce/webhooks/ses.go create mode 100644 internal/migrations/v2.0.0.go 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 fb07f159c7e45eed1e3aa1c73df3ccd21304c4e4..af01501d3638a8424353d45083e936b66d1eb749 100644 GIT binary patch literal 7288 zcmV-;9Eam~Pew8T0RR91033J#4*&oF05`}00303w0RR9100000000000000000000 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 ShUFV}-6>)H#fCW;s{jDST*{*W literal 6824 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 W=A+bnTZ@66>2Cg+p3T1tFb3!)8PiAr 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 @@ + +

+
+ + + +
+
+ + + +
+
+ + + + + + +
+
+ +
+ + + + + + + + +
@@ -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 @@
+ + +

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

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