mirror of
https://github.com/knadh/listmonk.git
synced 2025-10-04 20:34:55 +08:00
Add subsriber blocklisting on the bounces UI (#2409)
Co-authored-by: Kailash Nadh <kailash@nadh.in>
This commit is contained in:
parent
c9c678c04f
commit
ba24c64fae
12 changed files with 126 additions and 42 deletions
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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"))
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -848,6 +848,9 @@ section.lists {
|
|||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
.blocklisted {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
|
||||
/* Import page */
|
||||
|
|
|
@ -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">
|
||||
—
|
||||
<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() {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)))
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -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"`
|
||||
|
|
14
queries.sql
14
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()),
|
||||
|
|
Loading…
Add table
Reference in a new issue