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.
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
}

View file

@ -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"))

View file

@ -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) {

View file

@ -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:

View file

@ -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,

View file

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

View file

@ -7,25 +7,43 @@
<span v-if="bounces.total > 0">({{ bounces.total }})</span>
</h1>
</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>
<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
paginated backend-pagination pagination-position="both" @page-change="onPageChange" :current-page="queryParams.page" :per-page="bounces.perPage"
:total="bounces.total" backend-sorting @sort="onSort">
paginated backend-pagination pagination-position="both" @page-change="onPageChange"
: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>
<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 }}
<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>
</b-table-column>
@ -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() {

View file

@ -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",

View file

@ -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)))

View file

@ -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"`

View file

@ -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"`

View file

@ -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()),