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 }}) -
- - {{ $t('globals.buttons.clear') }} - - - {{ $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()),