mirror of
https://github.com/knadh/listmonk.git
synced 2025-10-06 05:16:48 +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.
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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"))
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 */
|
||||||
|
|
|
@ -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">
|
||||||
|
—
|
||||||
|
<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() {
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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)))
|
||||||
|
|
|
@ -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"`
|
||||||
|
|
|
@ -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"`
|
||||||
|
|
14
queries.sql
14
queries.sql
|
@ -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()),
|
||||||
|
|
Loading…
Add table
Reference in a new issue