Add subsriber blocklisting on the bounces UI (#2409)

Co-authored-by: Kailash Nadh <kailash@nadh.in>
This commit is contained in:
Bowrna 2025-08-01 23:21:25 +05:30 committed by GitHub
parent c9c678c04f
commit ba24c64fae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 126 additions and 42 deletions

View file

@ -86,7 +86,7 @@ func (a *App) DeleteBounces(c echo.Context) error {
} }
// Delete bounces from the DB. // Delete bounces from the DB.
if err := a.core.DeleteBounces(ids); err != nil { if err := a.core.DeleteBounces(ids, all); err != nil {
return err return err
} }
@ -97,7 +97,16 @@ func (a *App) DeleteBounces(c echo.Context) error {
func (a *App) DeleteBounce(c echo.Context) error { func (a *App) DeleteBounce(c echo.Context) error {
// Delete bounces from the DB. // Delete bounces from the DB.
id := getID(c) 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 return err
} }

View file

@ -121,6 +121,7 @@ func initHTTPHandlers(e *echo.Echo, a *App) {
g.DELETE("/api/subscribers", pm(a.DeleteSubscribers, "subscribers:manage")) g.DELETE("/api/subscribers", pm(a.DeleteSubscribers, "subscribers:manage"))
g.GET("/api/bounces", pm(a.GetBounces, "bounces:get")) 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.GET("/api/bounces/:id", pm(hasID(a.GetBounce), "bounces:get"))
g.DELETE("/api/bounces", pm(a.DeleteBounces, "bounces:manage")) g.DELETE("/api/bounces", pm(a.DeleteBounces, "bounces:manage"))
g.DELETE("/api/bounces/:id", pm(hasID(a.DeleteBounce), "bounces:manage")) g.DELETE("/api/bounces/:id", pm(hasID(a.DeleteBounce), "bounces:manage"))

View file

@ -458,7 +458,13 @@ func (a *App) BlocklistSubscribersByQuery(c echo.Context) error {
req.Search = strings.TrimSpace(req.Search) req.Search = strings.TrimSpace(req.Search)
req.Query = formatSQLExp(req.Query) 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? // Does the user have the subscribers:sql_query permission?
if req.Query != "" { if req.Query != "" {
if !user.HasPerm(auth.PermSubscribersSqlQuery) { if !user.HasPerm(auth.PermSubscribersSqlQuery) {

View file

@ -817,7 +817,7 @@ paths:
page: page:
type: integer type: integer
delete: delete:
description: handles retrieval of bounce records. description: handles deletion of bounce records.
operationId: deleteBounces operationId: deleteBounces
tags: tags:
- Bounces - Bounces
@ -889,7 +889,7 @@ paths:
properties: properties:
data: data:
type: boolean type: boolean
/lists: /lists:
get: get:
description: retrieves lists with additional metadata like subscriber counts. This may be slow. description: retrieves lists with additional metadata like subscriber counts. This may be slow.
@ -2160,6 +2160,10 @@ components:
type: string type: string
analytics.toDate: analytics.toDate:
type: string type: string
bounces.numSelected:
type: string
bounces.selectAll:
type: string
bounces.source: bounces.source:
type: string type: string
bounces.unknownService: bounces.unknownService:

View file

@ -189,6 +189,11 @@ export const deleteBounces = async (params) => http.delete(
{ params, loading: models.bounces }, { params, loading: models.bounces },
); );
export const blocklistBouncedSubscribers = async () => http.put(
'/api/bounces/blocklist',
{ loading: models.bounces },
);
export const createSubscriber = (data) => http.post( export const createSubscriber = (data) => http.post(
'/api/subscribers', '/api/subscribers',
data, data,

View file

@ -848,6 +848,9 @@ section.lists {
overflow-x: auto; overflow-x: auto;
max-width: 100%; max-width: 100%;
} }
.blocklisted {
color: red;
}
} }
/* Import page */ /* Import page */

View file

@ -7,25 +7,43 @@
<span v-if="bounces.total > 0">({{ bounces.total }})</span> <span v-if="bounces.total > 0">({{ bounces.total }})</span>
</h1> </h1>
</div> </div>
<div class="column has-text-right buttons">
<b-button v-if="bulk.checked.length > 0 || bulk.all" type="is-primary" icon-left="trash-can-outline"
data-cy="btn-delete" @click.prevent="$utils.confirm(null, () => deleteBounces())">
{{ $t('globals.buttons.clear') }}
</b-button>
<b-button v-if="bounces.total" icon-left="trash-can-outline" data-cy="btn-delete"
@click.prevent="$utils.confirm(null, () => deleteBounces(true))">
{{ $t('globals.buttons.clearAll') }}
</b-button>
</div>
</header> </header>
<b-table :data="bounces.results" :hoverable="true" :loading="loading.bounces" default-sort="createdAt" checkable <b-table :data="bounces.results" :hoverable="true" :loading="loading.bounces" default-sort="createdAt" checkable
@check-all="onTableCheck" @check="onTableCheck" :checked-rows.sync="bulk.checked" detailed show-detail-icon @check-all="onTableCheck" @check="onTableCheck" :checked-rows.sync="bulk.checked" detailed show-detail-icon
paginated backend-pagination pagination-position="both" @page-change="onPageChange" :current-page="queryParams.page" :per-page="bounces.perPage" paginated backend-pagination pagination-position="both" @page-change="onPageChange"
:total="bounces.total" backend-sorting @sort="onSort"> :current-page="queryParams.page" :per-page="bounces.perPage" :total="bounces.total" backend-sorting
@sort="onSort">
<template #top-left>
<div class="actions">
<template v-if="bulk.checked.length > 0">
<a class="a" href="#" @click.prevent="$utils.confirm(null, () => deleteBounces())" data-cy="btn-delete">
<b-icon icon="trash-can-outline" size="is-small" /> {{ $t('globals.buttons.delete') }}
</a>
<a class="a" href="#" @click.prevent="$utils.confirm(null, () => blocklistSubscribers())"
data-cy="btn-manage-blocklist">
<b-icon icon="account-off-outline" size="is-small" /> {{ $t('import.blocklist') }}
</a>
<span>
{{ $t('globals.messages.numSelected', { num: numSelectedBounces }) }}
<span v-if="!bulk.all && bounces.total > bounces.perPage">
&mdash;
<a href="#" @click.prevent="selectAllBounces">
{{ $t('subscribers.selectAll', { num: bounces.total }) }}
</a>
</span>
</span>
</template>
</div>
</template>
<b-table-column v-slot="props" field="email" :label="$t('subscribers.email')" :td-attrs="$utils.tdID" sortable> <b-table-column v-slot="props" field="email" :label="$t('subscribers.email')" :td-attrs="$utils.tdID" sortable>
<router-link :to="{ name: 'subscriber', params: { id: props.row.subscriberId } }"> <router-link :to="{ name: 'subscriber', params: { id: props.row.subscriberId } }"
:class="{ 'blocklisted': props.row.subscriberStatus === 'blocklisted' }">
{{ props.row.email }} {{ props.row.email }}
<b-tag v-if="props.row.subscriberStatus !== 'enabled'" :class="props.row.subscriberStatus"
data-cy="blocklisted">
{{ $t(`subscribers.status.${props.row.subscriberStatus}`) }}
</b-tag>
</router-link> </router-link>
</b-table-column> </b-table-column>
@ -119,7 +137,10 @@ export default Vue.extend({
this.queryParams.page = p; this.queryParams.page = p;
this.getBounces(); this.getBounces();
}, },
// Mark all bounces in the query as selected.
selectAllBounces() {
this.bulk.all = true;
},
onTableCheck() { onTableCheck() {
// Disable bulk.all selection if there are no rows checked in the table. // Disable bulk.all selection if there are no rows checked in the table.
if (this.bulk.checked.length !== this.bounces.total) { if (this.bulk.checked.length !== this.bounces.total) {
@ -149,35 +170,47 @@ export default Vue.extend({
}); });
}, },
deleteBounces(all) { deleteBounces() {
const fnSuccess = () => { 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.getBounces();
this.$utils.toast(this.$t( this.$utils.toast(this.$t(
'globals.messages.deletedCount', '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) { if (!this.bulk.all && this.bulk.checked.length > 0) {
this.$api.deleteBounces({ all: true }).then(fnSuccess); const subIds = this.bulk.checked.map((s) => s.subscriberId);
this.$api.blocklistSubscribers({ ids: subIds }).then(cb);
return; return;
} }
const ids = this.bulk.checked.map((s) => s.id); this.$api.blocklistBouncedSubscribers({ all: true }).then(cb);
this.$api.deleteBounces({ id: ids }).then(fnSuccess);
}, },
}, },
computed: { computed: {
...mapState(['templates', 'loading']), ...mapState(['templates', 'loading']),
numSelectedBounces() {
selectedBounces() {
if (this.bulk.all) { if (this.bulk.all) {
return this.bounces.total; return this.bounces.total;
} }
return this.bulk.checked.length; return this.bulk.checked.length;
}, },
}, },
mounted() { mounted() {

View file

@ -174,6 +174,7 @@
"globals.fields.type": "Type", "globals.fields.type": "Type",
"globals.fields.updatedAt": "Updated", "globals.fields.updatedAt": "Updated",
"globals.fields.uuid": "UUID", "globals.fields.uuid": "UUID",
"globals.messages.numSelected": "{num} selected",
"globals.messages.confirm": "Are you sure?", "globals.messages.confirm": "Are you sure?",
"globals.messages.confirmDiscard": "Discard changes?", "globals.messages.confirmDiscard": "Discard changes?",
"globals.messages.copied": "Copied", "globals.messages.copied": "Copied",

View file

@ -86,14 +86,24 @@ func (c *Core) RecordBounce(b models.Bounce) error {
return err 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. // DeleteBounce deletes a list.
func (c *Core) DeleteBounce(id int) error { func (c *Core) DeleteBounce(id int) error {
return c.DeleteBounces([]int{id}) return c.DeleteBounces([]int{id}, false)
} }
// DeleteBounces deletes multiple lists. // DeleteBounces deletes multiple lists.
func (c *Core) DeleteBounces(ids []int) error { func (c *Core) DeleteBounces(ids []int, all bool) error {
if _, err := c.q.DeleteBounces.Exec(pq.Array(ids)); err != nil { if _, err := c.q.DeleteBounces.Exec(pq.Array(ids), all); err != nil {
c.log.Printf("error deleting lists: %v", err) c.log.Printf("error deleting lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.list}", "error", pqErrMsg(err))) c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.list}", "error", pqErrMsg(err)))

View file

@ -331,9 +331,10 @@ type Bounce struct {
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
// One of these should be provided. // One of these should be provided.
Email string `db:"email" json:"email,omitempty"` Email string `db:"email" json:"email,omitempty"`
SubscriberUUID string `db:"subscriber_uuid" json:"subscriber_uuid,omitempty"` SubscriberUUID string `db:"subscriber_uuid" json:"subscriber_uuid,omitempty"`
SubscriberID int `db:"subscriber_id" json:"subscriber_id,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"` CampaignUUID string `db:"campaign_uuid" json:"campaign_uuid,omitempty"`
Campaign *json.RawMessage `db:"campaign" json:"campaign"` Campaign *json.RawMessage `db:"campaign" json:"campaign"`

View file

@ -106,11 +106,12 @@ type Queries struct {
UpdateSettings *sqlx.Stmt `query:"update-settings"` UpdateSettings *sqlx.Stmt `query:"update-settings"`
// GetStats *sqlx.Stmt `query:"get-stats"` // GetStats *sqlx.Stmt `query:"get-stats"`
RecordBounce *sqlx.Stmt `query:"record-bounce"` RecordBounce *sqlx.Stmt `query:"record-bounce"`
QueryBounces string `query:"query-bounces"` QueryBounces string `query:"query-bounces"`
DeleteBounces *sqlx.Stmt `query:"delete-bounces"` BlocklistBouncedSubscribers *sqlx.Stmt `query:"blocklist-bounced-subscribers"`
DeleteBouncesBySubscriber *sqlx.Stmt `query:"delete-bounces-by-subscriber"` DeleteBounces *sqlx.Stmt `query:"delete-bounces"`
GetDBInfo string `query:"get-db-info"` DeleteBouncesBySubscriber *sqlx.Stmt `query:"delete-bounces-by-subscriber"`
GetDBInfo string `query:"get-db-info"`
CreateUser *sqlx.Stmt `query:"create-user"` CreateUser *sqlx.Stmt `query:"create-user"`
UpdateUser *sqlx.Stmt `query:"update-user"` UpdateUser *sqlx.Stmt `query:"update-user"`

View file

@ -1088,7 +1088,7 @@ SELECT COUNT(*) OVER () AS total,
bounces.subscriber_id, bounces.subscriber_id,
subscribers.uuid AS subscriber_uuid, subscribers.uuid AS subscriber_uuid,
subscribers.email AS email, subscribers.email AS email,
subscribers.email AS email, subscribers.status as subscriber_status,
( (
CASE WHEN bounces.campaign_id IS NOT NULL CASE WHEN bounces.campaign_id IS NOT NULL
THEN JSON_BUILD_OBJECT('id', bounces.campaign_id, 'name', campaigns.name) 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; ORDER BY %order% OFFSET $5 LIMIT $6;
-- name: delete-bounces -- 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 -- name: delete-bounces-by-subscriber
WITH sub AS ( WITH sub AS (
@ -1112,6 +1112,16 @@ WITH sub AS (
) )
DELETE FROM bounces WHERE subscriber_id = (SELECT id FROM sub); 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 -- name: get-db-info
SELECT JSON_BUILD_OBJECT('version', (SELECT VERSION()), SELECT JSON_BUILD_OBJECT('version', (SELECT VERSION()),