diff --git a/cmd/bounce.go b/cmd/bounce.go
index 4d30af20..6536f330 100644
--- a/cmd/bounce.go
+++ b/cmd/bounce.go
@@ -86,7 +86,7 @@ func (a *App) DeleteBounces(c echo.Context) error {
}
// Delete bounces from the DB.
- if err := a.core.DeleteBounces(ids); err != nil {
+ if err := a.core.DeleteBounces(ids, all); err != nil {
return err
}
@@ -97,7 +97,16 @@ func (a *App) DeleteBounces(c echo.Context) error {
func (a *App) DeleteBounce(c echo.Context) error {
// Delete bounces from the DB.
id := getID(c)
- if err := a.core.DeleteBounces([]int{id}); err != nil {
+ if err := a.core.DeleteBounces([]int{id}, false); err != nil {
+ return err
+ }
+
+ return c.JSON(http.StatusOK, okResp{true})
+}
+
+// BlocklistBouncedSubscribers handles blocklisting of all bounced subscribers.
+func (a *App) BlocklistBouncedSubscribers(c echo.Context) error {
+ if err := a.core.BlocklistBouncedSubscribers(); err != nil {
return err
}
diff --git a/cmd/handlers.go b/cmd/handlers.go
index f3aa6b51..7d201a8b 100644
--- a/cmd/handlers.go
+++ b/cmd/handlers.go
@@ -121,6 +121,7 @@ func initHTTPHandlers(e *echo.Echo, a *App) {
g.DELETE("/api/subscribers", pm(a.DeleteSubscribers, "subscribers:manage"))
g.GET("/api/bounces", pm(a.GetBounces, "bounces:get"))
+ g.PUT("/api/bounces/blocklist", pm(a.BlocklistBouncedSubscribers, "bounces:manage"))
g.GET("/api/bounces/:id", pm(hasID(a.GetBounce), "bounces:get"))
g.DELETE("/api/bounces", pm(a.DeleteBounces, "bounces:manage"))
g.DELETE("/api/bounces/:id", pm(hasID(a.DeleteBounce), "bounces:manage"))
diff --git a/cmd/subscribers.go b/cmd/subscribers.go
index 7675f1f6..8beb2640 100644
--- a/cmd/subscribers.go
+++ b/cmd/subscribers.go
@@ -458,7 +458,13 @@ func (a *App) BlocklistSubscribersByQuery(c echo.Context) error {
req.Search = strings.TrimSpace(req.Search)
req.Query = formatSQLExp(req.Query)
-
+ if req.All {
+ // If the "all" flag is set, ignore any subquery that may be present.
+ req.Search = ""
+ req.Query = ""
+ } else if req.Search == "" && req.Query == "" {
+ return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "query"))
+ }
// Does the user have the subscribers:sql_query permission?
if req.Query != "" {
if !user.HasPerm(auth.PermSubscribersSqlQuery) {
diff --git a/docs/swagger/collections.yaml b/docs/swagger/collections.yaml
index de988eb0..f2cfb060 100644
--- a/docs/swagger/collections.yaml
+++ b/docs/swagger/collections.yaml
@@ -817,7 +817,7 @@ paths:
page:
type: integer
delete:
- description: handles retrieval of bounce records.
+ description: handles deletion of bounce records.
operationId: deleteBounces
tags:
- Bounces
@@ -889,7 +889,7 @@ paths:
properties:
data:
type: boolean
-
+
/lists:
get:
description: retrieves lists with additional metadata like subscriber counts. This may be slow.
@@ -2160,6 +2160,10 @@ components:
type: string
analytics.toDate:
type: string
+ bounces.numSelected:
+ type: string
+ bounces.selectAll:
+ type: string
bounces.source:
type: string
bounces.unknownService:
diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js
index 9c9435df..61dc8678 100644
--- a/frontend/src/api/index.js
+++ b/frontend/src/api/index.js
@@ -189,6 +189,11 @@ export const deleteBounces = async (params) => http.delete(
{ params, loading: models.bounces },
);
+export const blocklistBouncedSubscribers = async () => http.put(
+ '/api/bounces/blocklist',
+ { loading: models.bounces },
+);
+
export const createSubscriber = (data) => http.post(
'/api/subscribers',
data,
diff --git a/frontend/src/assets/style.scss b/frontend/src/assets/style.scss
index b68cb0d8..a8e7e950 100644
--- a/frontend/src/assets/style.scss
+++ b/frontend/src/assets/style.scss
@@ -848,6 +848,9 @@ section.lists {
overflow-x: auto;
max-width: 100%;
}
+ .blocklisted {
+ color: red;
+ }
}
/* Import page */
diff --git a/frontend/src/views/Bounces.vue b/frontend/src/views/Bounces.vue
index c426e033..5a490f10 100644
--- a/frontend/src/views/Bounces.vue
+++ b/frontend/src/views/Bounces.vue
@@ -7,25 +7,43 @@
({{ bounces.total }})
-
- deleteBounces())">
- {{ $t('globals.buttons.clear') }}
-
- deleteBounces(true))">
- {{ $t('globals.buttons.clearAll') }}
-
-
+ 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 }}
+
+ {{ $t(`subscribers.status.${props.row.subscriberStatus}`) }}
+
@@ -119,7 +137,10 @@ export default Vue.extend({
this.queryParams.page = p;
this.getBounces();
},
-
+ // Mark all bounces in the query as selected.
+ selectAllBounces() {
+ this.bulk.all = true;
+ },
onTableCheck() {
// Disable bulk.all selection if there are no rows checked in the table.
if (this.bulk.checked.length !== this.bounces.total) {
@@ -149,35 +170,47 @@ export default Vue.extend({
});
},
- deleteBounces(all) {
- const fnSuccess = () => {
+ deleteBounces() {
+ const params = {};
+ if (!this.bulk.all && this.bulk.checked.length > 0) {
+ params.id = this.bulk.checked.map((s) => s.id);
+ } else if (this.bulk.all) {
+ params.all = true;
+ }
+
+ this.$api.deleteBounces(params).then(() => {
this.getBounces();
this.$utils.toast(this.$t(
'globals.messages.deletedCount',
- { name: this.$tc('globals.terms.bounces'), num: this.bounces.total },
+ { name: this.$tc('globals.terms.bounces'), num: this.numSelectedBounces },
));
+ });
+ },
+
+ blocklistSubscribers() {
+ const cb = () => {
+ this.getBounces();
+ this.$utils.toast(this.$t('globals.messages.done'));
};
- if (all) {
- this.$api.deleteBounces({ all: true }).then(fnSuccess);
+ if (!this.bulk.all && this.bulk.checked.length > 0) {
+ const subIds = this.bulk.checked.map((s) => s.subscriberId);
+ this.$api.blocklistSubscribers({ ids: subIds }).then(cb);
return;
}
- const ids = this.bulk.checked.map((s) => s.id);
- this.$api.deleteBounces({ id: ids }).then(fnSuccess);
+ this.$api.blocklistBouncedSubscribers({ all: true }).then(cb);
},
},
computed: {
...mapState(['templates', 'loading']),
-
- selectedBounces() {
+ numSelectedBounces() {
if (this.bulk.all) {
return this.bounces.total;
}
return this.bulk.checked.length;
},
-
},
mounted() {
diff --git a/i18n/en.json b/i18n/en.json
index 8b701ef6..94694b1b 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -174,6 +174,7 @@
"globals.fields.type": "Type",
"globals.fields.updatedAt": "Updated",
"globals.fields.uuid": "UUID",
+ "globals.messages.numSelected": "{num} selected",
"globals.messages.confirm": "Are you sure?",
"globals.messages.confirmDiscard": "Discard changes?",
"globals.messages.copied": "Copied",
diff --git a/internal/core/bounces.go b/internal/core/bounces.go
index 4ba61d11..1fc3732c 100644
--- a/internal/core/bounces.go
+++ b/internal/core/bounces.go
@@ -86,14 +86,24 @@ func (c *Core) RecordBounce(b models.Bounce) error {
return err
}
+// BlocklistBouncedSubscribers blocklists all bounced subscribers.
+func (c *Core) BlocklistBouncedSubscribers() error {
+ if _, err := c.q.BlocklistBouncedSubscribers.Exec(); err != nil {
+ c.log.Printf("error blocklisting bounced subscribers: %v", err)
+ return echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("subscribers.errorBlocklisting", "error", err.Error()))
+ }
+
+ return nil
+}
+
// DeleteBounce deletes a list.
func (c *Core) DeleteBounce(id int) error {
- return c.DeleteBounces([]int{id})
+ return c.DeleteBounces([]int{id}, false)
}
// DeleteBounces deletes multiple lists.
-func (c *Core) DeleteBounces(ids []int) error {
- if _, err := c.q.DeleteBounces.Exec(pq.Array(ids)); err != nil {
+func (c *Core) DeleteBounces(ids []int, all bool) error {
+ if _, err := c.q.DeleteBounces.Exec(pq.Array(ids), all); err != nil {
c.log.Printf("error deleting lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
diff --git a/models/models.go b/models/models.go
index 8fb6c639..d4a8e7b5 100644
--- a/models/models.go
+++ b/models/models.go
@@ -331,9 +331,10 @@ type Bounce struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
// One of these should be provided.
- Email string `db:"email" json:"email,omitempty"`
- SubscriberUUID string `db:"subscriber_uuid" json:"subscriber_uuid,omitempty"`
- SubscriberID int `db:"subscriber_id" json:"subscriber_id,omitempty"`
+ Email string `db:"email" json:"email,omitempty"`
+ SubscriberUUID string `db:"subscriber_uuid" json:"subscriber_uuid,omitempty"`
+ SubscriberID int `db:"subscriber_id" json:"subscriber_id,omitempty"`
+ SubscriberStatus string `db:"subscriber_status" json:"subscriber_status"`
CampaignUUID string `db:"campaign_uuid" json:"campaign_uuid,omitempty"`
Campaign *json.RawMessage `db:"campaign" json:"campaign"`
diff --git a/models/queries.go b/models/queries.go
index 506f44ba..4d1fe1e0 100644
--- a/models/queries.go
+++ b/models/queries.go
@@ -106,11 +106,12 @@ 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"`
- GetDBInfo string `query:"get-db-info"`
+ RecordBounce *sqlx.Stmt `query:"record-bounce"`
+ QueryBounces string `query:"query-bounces"`
+ BlocklistBouncedSubscribers *sqlx.Stmt `query:"blocklist-bounced-subscribers"`
+ DeleteBounces *sqlx.Stmt `query:"delete-bounces"`
+ DeleteBouncesBySubscriber *sqlx.Stmt `query:"delete-bounces-by-subscriber"`
+ GetDBInfo string `query:"get-db-info"`
CreateUser *sqlx.Stmt `query:"create-user"`
UpdateUser *sqlx.Stmt `query:"update-user"`
diff --git a/queries.sql b/queries.sql
index 81c8887e..c503075c 100644
--- a/queries.sql
+++ b/queries.sql
@@ -1088,7 +1088,7 @@ SELECT COUNT(*) OVER () AS total,
bounces.subscriber_id,
subscribers.uuid AS subscriber_uuid,
subscribers.email AS email,
- subscribers.email AS email,
+ subscribers.status as subscriber_status,
(
CASE WHEN bounces.campaign_id IS NOT NULL
THEN JSON_BUILD_OBJECT('id', bounces.campaign_id, 'name', campaigns.name)
@@ -1104,7 +1104,7 @@ WHERE ($1 = 0 OR bounces.id = $1)
ORDER BY %order% OFFSET $5 LIMIT $6;
-- name: delete-bounces
-DELETE FROM bounces WHERE CARDINALITY($1::INT[]) = 0 OR id = ANY($1);
+DELETE FROM bounces WHERE $2 = TRUE OR id = ANY($1);
-- name: delete-bounces-by-subscriber
WITH sub AS (
@@ -1112,6 +1112,16 @@ WITH sub AS (
)
DELETE FROM bounces WHERE subscriber_id = (SELECT id FROM sub);
+-- name: blocklist-bounced-subscribers
+WITH subs AS (
+ SELECT subscriber_id FROM bounces
+),
+b AS (
+ UPDATE subscribers SET status='blocklisted', updated_at=NOW()
+ WHERE id = ANY(SELECT subscriber_id FROM subs)
+)
+UPDATE subscriber_lists SET status='unsubscribed', updated_at=NOW()
+ WHERE subscriber_id = ANY(SELECT subscriber_id FROM subs);
-- name: get-db-info
SELECT JSON_BUILD_OBJECT('version', (SELECT VERSION()),