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 @@
{{ $t('globals.terms.bounces') }}
+ ({{ bounces.total }})
+ {{ props.row.meta }}
+
+
+
+
+
+
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 @@
+