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 @@
+
+
+
+
+
{{ $t('globals.terms.bounces') }}
+ ({{ bounces.total }})
+
+
+ deleteBounces())">
+ {{ $t('globals.buttons.delete') }}
+
+ deleteBounces(true))">
+ {{ $t('globals.buttons.deleteAll') }}
+
+
+
+
+ $buefy.toast.open(`Expanded ${row.user.first_name}`)"
+ paginated backend-pagination pagination-position="both" @page-change="onPageChange"
+ :current-page="queryParams.page" :per-page="bounces.perPage" :total="bounces.total"
+ backend-sorting @sort="onSort">
+
+
+ {{ props.row.email }}
+
+
+
+
+
+ {{ props.row.campaign.name }}
+
+
+
+
+
+ {{ props.row.source }}
+
+
+
+
+ {{ $utils.niceDate(props.row.createdAt, true) }}
+
+
+
+
+
+
+
+ {{ props.row.meta }}
+
+
+
+
+
+
+
+
+
+
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 @@