From 568659f36c3c9a591862dbc7314d66ddfee3f2b8 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 23 Apr 2026 12:23:43 +0400 Subject: [PATCH 01/11] Feat: bounces --- assets/router/index.js | 2 + assets/vue/api.js | 29 +++ .../vue/components/bounces/BounceOverview.vue | 230 ++++++++++++++++++ .../bounces/BouncesActionsPanel.vue | 212 ++++++++++++++++ assets/vue/views/BouncesView.vue | 12 + package.json | 2 +- src/Controller/BouncesController.php | 24 ++ .../Controller/BouncesControllerTest.php | 44 ++++ yarn.lock | 8 +- 9 files changed, 558 insertions(+), 5 deletions(-) create mode 100644 assets/vue/components/bounces/BounceOverview.vue create mode 100644 assets/vue/components/bounces/BouncesActionsPanel.vue create mode 100644 assets/vue/views/BouncesView.vue create mode 100644 src/Controller/BouncesController.php create mode 100644 tests/Integration/Controller/BouncesControllerTest.php diff --git a/assets/router/index.js b/assets/router/index.js index e1bfe66..f1d66d3 100644 --- a/assets/router/index.js +++ b/assets/router/index.js @@ -7,6 +7,7 @@ import CampaignsView from '../vue/views/CampaignsView.vue' import CampaignEditView from '../vue/views/CampaignEditView.vue' import TemplatesView from '../vue/views/TemplatesView.vue' import TemplateEditView from '../vue/views/TemplateEditView.vue' +import BouncesView from '../vue/views/BouncesView.vue' export const router = createRouter({ history: createWebHistory(), @@ -21,6 +22,7 @@ export const router = createRouter({ { path: '/campaigns/create', name: 'campaign-create', component: CampaignEditView, meta: { title: 'Create Campaign' } }, { path: '/campaigns/:campaignId/edit', name: 'campaign-edit', component: CampaignEditView, meta: { title: 'Edit Campaign' } }, { path: '/lists/:listId/subscribers', name: 'list-subscribers', component: ListSubscribersView, meta: { title: 'List Subscribers' } }, + { path: '/bounces', name: 'bounces', component: BouncesView, meta: { title: 'Bounces' } }, { path: '/:pathMatch(.*)*', redirect: '/' }, ], }); diff --git a/assets/vue/api.js b/assets/vue/api.js index 7cc9e77..13c1417 100644 --- a/assets/vue/api.js +++ b/assets/vue/api.js @@ -55,4 +55,33 @@ export const fetchAllLists = async ({ limit = 100, maxPages = 100 } = {}) => { return lists; }; +export const fetchAllBounces = async ({ limit = 100, maxPages = 100 } = {}) => { + const bounces = []; + let afterId = null; + + for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { + const query = { limit }; + if (afterId !== null) { + query.after_id = afterId; + } + + const response = await client.get('bounces', query); + const items = Array.isArray(response?.items) ? response.items : []; + bounces.push(...items); + + const pagination = response?.pagination ?? {}; + const hasMore = pagination.hasMore === true || pagination.has_more === true; + const nextCursor = pagination.nextCursor ?? pagination.next_cursor ?? null; + + if (!hasMore || !Number.isFinite(nextCursor) || nextCursor === afterId) { + break; + } + + afterId = nextCursor; + } + + bounces.sort((a, b) => Number(b.id ?? 0) - Number(a.id ?? 0)); + return bounces; +}; + export default client; diff --git a/assets/vue/components/bounces/BounceOverview.vue b/assets/vue/components/bounces/BounceOverview.vue new file mode 100644 index 0000000..699b79a --- /dev/null +++ b/assets/vue/components/bounces/BounceOverview.vue @@ -0,0 +1,230 @@ + + + diff --git a/assets/vue/components/bounces/BouncesActionsPanel.vue b/assets/vue/components/bounces/BouncesActionsPanel.vue new file mode 100644 index 0000000..b114c99 --- /dev/null +++ b/assets/vue/components/bounces/BouncesActionsPanel.vue @@ -0,0 +1,212 @@ + + + diff --git a/assets/vue/views/BouncesView.vue b/assets/vue/views/BouncesView.vue new file mode 100644 index 0000000..7ca26b6 --- /dev/null +++ b/assets/vue/views/BouncesView.vue @@ -0,0 +1,12 @@ + + + diff --git a/package.json b/package.json index 43f74e5..c6d8fb8 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ }, "dependencies": { "@ckeditor/ckeditor5-vue": "^7.4.0", - "@tatevikgr/rest-api-client": "^2.1.0", + "@tatevikgr/rest-api-client": "^2.1.1", "apexcharts": "^5.10.4", "ckeditor5": "^48.0.0", "vue": "^3.5.16", diff --git a/src/Controller/BouncesController.php b/src/Controller/BouncesController.php new file mode 100644 index 0000000..e139b29 --- /dev/null +++ b/src/Controller/BouncesController.php @@ -0,0 +1,24 @@ +render('@PhpListFrontend/spa.html.twig', [ + 'page' => 'Bounces', + 'api_token' => $request->getSession()->get('auth_token'), + 'api_base_url' => $this->getParameter('api_base_url'), + ]); + } +} diff --git a/tests/Integration/Controller/BouncesControllerTest.php b/tests/Integration/Controller/BouncesControllerTest.php new file mode 100644 index 0000000..75dce39 --- /dev/null +++ b/tests/Integration/Controller/BouncesControllerTest.php @@ -0,0 +1,44 @@ +get('router'); + + self::assertSame('/bounces/', $router->generate('bounce_list')); + } + + public function testBouncesPageRendersExpectedSpaPayload(): void + { + self::bootKernel(); + /** @var BouncesController $controller */ + $controller = static::getContainer()->get(BouncesController::class); + + $request = Request::create('/bounces/'); + $session = new Session(new MockArraySessionStorage()); + $session->set('auth_token', 'integration-token'); + $request->setSession($session); + + $response = $controller->index($request); + $content = (string) $response->getContent(); + + self::assertSame(200, $response->getStatusCode()); + self::assertStringContainsString('phpList - Bounces', $content); + self::assertStringContainsString('data-api-token="integration-token"', $content); + self::assertStringContainsString('data-api-base-url="http://api.phplist.local/"', $content); + } +} diff --git a/yarn.lock b/yarn.lock index 7e07567..5e3a85a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2119,10 +2119,10 @@ postcss "^8.5.6" tailwindcss "4.2.1" -"@tatevikgr/rest-api-client@^2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@tatevikgr/rest-api-client/-/rest-api-client-2.1.0.tgz#9b0f519378fd9301e7fdb8124f11d3bc662e791f" - integrity sha512-Gp0L67gMMmZK12McgBMxOb8+PtDr0nnZC1B1aO2aGTz3nz3lQ7GvwQpVFNTbq0pQWuDG4rxWS1tJM3nttgV3oQ== +"@tatevikgr/rest-api-client@^2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@tatevikgr/rest-api-client/-/rest-api-client-2.1.1.tgz#6bfcea3f113ee6715c29c3e07eca9120dd743308" + integrity sha512-F/KrNGwEcEarRGRY4oN1VQDc7EDYNbCz1aEewzmTTNt1a2hNCiSjLmXBToYrZDPfscpmdv9GJ6PPhjm4RA4h+g== dependencies: axios "^1.6.0" From c79eda723cbf6cdd61be0fcd1bff76ce840f1c0f Mon Sep 17 00:00:00 2001 From: Tatevik Date: Mon, 27 Apr 2026 12:30:29 +0400 Subject: [PATCH 02/11] Bounces paginated --- assets/vue/api.js | 33 ++----------------- .../vue/components/bounces/BounceOverview.vue | 25 ++++++++++++-- package.json | 2 +- yarn.lock | 8 ++--- 4 files changed, 30 insertions(+), 38 deletions(-) diff --git a/assets/vue/api.js b/assets/vue/api.js index 13c1417..65fabaf 100644 --- a/assets/vue/api.js +++ b/assets/vue/api.js @@ -7,7 +7,8 @@ import { SubscribersClient, SubscriptionClient, SubscriberAttributesClient, - TemplatesClient + TemplatesClient, + BouncesClient, } from '@tatevikgr/rest-api-client'; const appElement = document.getElementById('vue-app'); @@ -32,6 +33,7 @@ export const statisticsClient = new StatisticsClient(client); export const subscriptionClient = new SubscriptionClient(client); export const subscriberAttributesClient = new SubscriberAttributesClient(client); export const templateClient = new TemplatesClient(client); +export const bouncesClient = new BouncesClient(client); export const fetchAllLists = async ({ limit = 100, maxPages = 100 } = {}) => { const lists = []; @@ -55,33 +57,4 @@ export const fetchAllLists = async ({ limit = 100, maxPages = 100 } = {}) => { return lists; }; -export const fetchAllBounces = async ({ limit = 100, maxPages = 100 } = {}) => { - const bounces = []; - let afterId = null; - - for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { - const query = { limit }; - if (afterId !== null) { - query.after_id = afterId; - } - - const response = await client.get('bounces', query); - const items = Array.isArray(response?.items) ? response.items : []; - bounces.push(...items); - - const pagination = response?.pagination ?? {}; - const hasMore = pagination.hasMore === true || pagination.has_more === true; - const nextCursor = pagination.nextCursor ?? pagination.next_cursor ?? null; - - if (!hasMore || !Number.isFinite(nextCursor) || nextCursor === afterId) { - break; - } - - afterId = nextCursor; - } - - bounces.sort((a, b) => Number(b.id ?? 0) - Number(a.id ?? 0)); - return bounces; -}; - export default client; diff --git a/assets/vue/components/bounces/BounceOverview.vue b/assets/vue/components/bounces/BounceOverview.vue index 699b79a..dc6b973 100644 --- a/assets/vue/components/bounces/BounceOverview.vue +++ b/assets/vue/components/bounces/BounceOverview.vue @@ -113,7 +113,7 @@ diff --git a/assets/vue/components/bounces/BouncesActionsPanel.vue b/assets/vue/components/bounces/BouncesActionsPanel.vue index b114c99..f4fa4bf 100644 --- a/assets/vue/components/bounces/BouncesActionsPanel.vue +++ b/assets/vue/components/bounces/BouncesActionsPanel.vue @@ -7,10 +7,10 @@ Monitor, process and automate bounce handling across all campaigns

- + + + +
@@ -22,7 +22,7 @@ :class="activeTab === tab.id ? 'bg-white text-slate-900 shadow-sm border border-slate-200' : 'text-slate-500 hover:text-slate-700 hover:bg-white/60'" - @click="activeTab = tab.id" + @click="setActiveTab(tab.id)" > {{ tab.label }} From 675b7cf5a03b69b1c2877f73f544b45b305ecd9d Mon Sep 17 00:00:00 2001 From: Tatevik Date: Tue, 28 Apr 2026 16:09:05 +0400 Subject: [PATCH 04/11] Bounces filter by status --- .../vue/components/bounces/BounceOverview.vue | 48 ++++++++++++++----- package.json | 2 +- yarn.lock | 8 ++-- 3 files changed, 40 insertions(+), 18 deletions(-) diff --git a/assets/vue/components/bounces/BounceOverview.vue b/assets/vue/components/bounces/BounceOverview.vue index dc6b973..c078e9a 100644 --- a/assets/vue/components/bounces/BounceOverview.vue +++ b/assets/vue/components/bounces/BounceOverview.vue @@ -1,8 +1,20 @@ + + @@ -70,13 +177,116 @@ import {onMounted, ref} from "vue"; import { bouncesClient} from "../../api"; const allBounceRules = ref([]) +const isCreateModalOpen = ref(false) +const isCreatingRule = ref(false) +const createError = ref('') +const createForm = ref({ + regex: '', + comment: '', + action: '', + status: 'active', + list_order: '', +}) + +const appElement = document.getElementById('vue-app') +const parseBounceActions = () => { + const raw = appElement?.dataset.bounceActions + if (!raw) { + return {} + } + + try { + return JSON.parse(raw) + } catch { + return {} + } +} +const bounceActions = parseBounceActions() + +const resetCreateForm = () => { + createForm.value = { + regex: '', + comment: '', + action: '', + status: 'active', + list_order: '', + } + createError.value = '' +} const loadBounceRules = async () => { - const bounceRules = await bouncesClient.listRegex() - if (!bounceRules) { - console.error('Failed to fetch bounce rules') + try { + const bounceRules = await bouncesClient.listRegex() + allBounceRules.value = Array.isArray(bounceRules) ? bounceRules : [] + } catch (error) { + if (error?.name === 'AuthenticationException' || error?.status === 401) { + window.location.href = '/login' + return + } + console.error('Failed to fetch bounce rules', error) + allBounceRules.value = [] + } +} + +const openCreateModal = () => { + resetCreateForm() + isCreateModalOpen.value = true +} + +const closeCreateModal = () => { + if (isCreatingRule.value) { + return + } + isCreateModalOpen.value = false +} + +const submitCreateRule = async () => { + if (isCreatingRule.value) { + return + } + + const regex = createForm.value.regex.trim() + if (!regex) { + createError.value = 'Regex is required.' + return + } + + const payload = { regex } + + const comment = createForm.value.comment.trim() + if (comment) { + payload.comment = comment + } + + if (createForm.value.action) { + payload.action = createForm.value.action + } + + if (createForm.value.status) { + payload.status = createForm.value.status + } + + if (createForm.value.list_order !== '') { + const parsedListOrder = Number(createForm.value.list_order) + if (!Number.isInteger(parsedListOrder) || parsedListOrder < 0) { + createError.value = 'List Order must be a whole number greater than or equal to 0.' + return + } + payload.list_order = parsedListOrder + } + + isCreatingRule.value = true + createError.value = '' + + try { + await bouncesClient.upsertRegex(payload) + isCreateModalOpen.value = false + await loadBounceRules() + } catch (error) { + createError.value = error?.message ?? 'Failed to create rule.' + } finally { + isCreatingRule.value = false } - allBounceRules.value = bounceRules } onMounted(() => { diff --git a/src/Controller/BouncesController.php b/src/Controller/BouncesController.php index e139b29..14fb3f2 100644 --- a/src/Controller/BouncesController.php +++ b/src/Controller/BouncesController.php @@ -4,6 +4,7 @@ namespace PhpList\WebFrontend\Controller; +use PhpList\Core\Domain\Messaging\Model\BounceAction; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -15,10 +16,16 @@ class BouncesController extends AbstractController #[Route('/', name: 'list', methods: ['GET'])] public function index(Request $request): Response { + $bounceActions = []; + foreach (BounceAction::cases() as $case) { + $bounceActions[$case->name] = $case->value; + } + return $this->render('@PhpListFrontend/spa.html.twig', [ 'page' => 'Bounces', 'api_token' => $request->getSession()->get('auth_token'), 'api_base_url' => $this->getParameter('api_base_url'), + 'bounce_actions' => $bounceActions, ]); } } diff --git a/templates/spa.html.twig b/templates/spa.html.twig index bfdd51e..d478a74 100644 --- a/templates/spa.html.twig +++ b/templates/spa.html.twig @@ -3,9 +3,10 @@ {% block title %}phpList - {{ page }}{% endblock %} {% block body %} -
+ data-dashboard-stats="{{ dashboard_stats|default({})|json_encode|e('html_attr') }}" + data-bounce-actions="{{ bounce_actions|default({})|json_encode|e('html_attr') }}">
{% endblock %} From 22aa3960cb97cbce7ced99cb0951974f882eb452 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 29 Apr 2026 14:59:45 +0400 Subject: [PATCH 07/11] Create form errors --- assets/vue/components/bounces/BounceRules.vue | 126 ++++++++++++++++-- 1 file changed, 118 insertions(+), 8 deletions(-) diff --git a/assets/vue/components/bounces/BounceRules.vue b/assets/vue/components/bounces/BounceRules.vue index b897d18..d9cb3f3 100644 --- a/assets/vue/components/bounces/BounceRules.vue +++ b/assets/vue/components/bounces/BounceRules.vue @@ -95,8 +95,20 @@ v-model.trim="createForm.regex" type="text" required - class="mt-1 block w-full border border-slate-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" + :class="[ + 'mt-1 block w-full rounded-md shadow-sm py-2 px-3 focus:outline-none sm:text-sm', + fieldHasError('regex') + ? 'border border-red-300 focus:ring-red-500 focus:border-red-500' + : 'border border-slate-300 focus:ring-blue-500 focus:border-blue-500' + ]" > +

+ {{ message }} +

@@ -105,8 +117,20 @@ id="bounce-rule-comment" v-model.trim="createForm.comment" type="text" - class="mt-1 block w-full border border-slate-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" + :class="[ + 'mt-1 block w-full rounded-md shadow-sm py-2 px-3 focus:outline-none sm:text-sm', + fieldHasError('comment') + ? 'border border-red-300 focus:ring-red-500 focus:border-red-500' + : 'border border-slate-300 focus:ring-blue-500 focus:border-blue-500' + ]" + > +

+ {{ message }} +

@@ -115,10 +139,22 @@ +

+ {{ message }} +

@@ -126,11 +162,23 @@ +

+ {{ message }} +

@@ -142,8 +190,20 @@ type="number" min="0" step="1" - class="mt-1 block w-full border border-slate-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" + :class="[ + 'mt-1 block w-full rounded-md shadow-sm py-2 px-3 focus:outline-none sm:text-sm', + fieldHasError('list_order') + ? 'border border-red-300 focus:ring-red-500 focus:border-red-500' + : 'border border-slate-300 focus:ring-blue-500 focus:border-blue-500' + ]" + > +

+ {{ message }} +

{{ createError }}

@@ -180,6 +240,7 @@ const allBounceRules = ref([]) const isCreateModalOpen = ref(false) const isCreatingRule = ref(false) const createError = ref('') +const createFieldErrors = ref({}) const createForm = ref({ regex: '', comment: '', @@ -212,8 +273,48 @@ const resetCreateForm = () => { list_order: '', } createError.value = '' + createFieldErrors.value = {} } +const normalizeValidationErrors = (error) => { + const responseData = error?.responseData + if (!responseData || typeof responseData !== 'object' || Array.isArray(responseData)) { + return {} + } + + const sourceErrors = + responseData.errors && typeof responseData.errors === 'object' && !Array.isArray(responseData.errors) + ? responseData.errors + : responseData + + const normalized = {} + + Object.entries(sourceErrors).forEach(([field, messages]) => { + if (!field || messages === null || messages === undefined) { + return + } + + const key = String(field) + const list = Array.isArray(messages) ? messages : [messages] + const textMessages = list + .map((message) => String(message).trim()) + .filter(Boolean) + + if (textMessages.length > 0) { + normalized[key] = textMessages + } + }) + + return normalized +} + +const fieldErrors = (field) => { + const messages = createFieldErrors.value?.[field] + return Array.isArray(messages) ? messages : [] +} + +const fieldHasError = (field) => fieldErrors(field).length > 0 + const loadBounceRules = async () => { try { const bounceRules = await bouncesClient.listRegex() @@ -247,7 +348,8 @@ const submitCreateRule = async () => { const regex = createForm.value.regex.trim() if (!regex) { - createError.value = 'Regex is required.' + createFieldErrors.value = { regex: ['Regex is required.'] } + createError.value = '' return } @@ -269,7 +371,11 @@ const submitCreateRule = async () => { if (createForm.value.list_order !== '') { const parsedListOrder = Number(createForm.value.list_order) if (!Number.isInteger(parsedListOrder) || parsedListOrder < 0) { - createError.value = 'List Order must be a whole number greater than or equal to 0.' + createFieldErrors.value = { + ...createFieldErrors.value, + list_order: ['List Order must be a whole number greater than or equal to 0.'] + } + createError.value = '' return } payload.list_order = parsedListOrder @@ -277,13 +383,17 @@ const submitCreateRule = async () => { isCreatingRule.value = true createError.value = '' + createFieldErrors.value = {} try { await bouncesClient.upsertRegex(payload) isCreateModalOpen.value = false await loadBounceRules() } catch (error) { - createError.value = error?.message ?? 'Failed to create rule.' + createFieldErrors.value = normalizeValidationErrors(error) + createError.value = Object.keys(createFieldErrors.value).length > 0 + ? '' + : error?.message ?? 'Failed to create rule.' } finally { isCreatingRule.value = false } From d790495c8328682bcce1a56cae6648f76808dded Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 30 Apr 2026 12:00:14 +0400 Subject: [PATCH 08/11] Update dependencies to main branches --- composer.json | 4 +- openapi.json | 926 +++++++++++++++--- package.json | 2 +- .../PhpListFrontendExtension.php | 50 +- yarn.lock | 8 +- 5 files changed, 807 insertions(+), 183 deletions(-) diff --git a/composer.json b/composer.json index af06be9..af8f27f 100755 --- a/composer.json +++ b/composer.json @@ -47,11 +47,11 @@ }, "require": { "php": "^8.1", - "phplist/core": "dev-dev", + "phplist/core": "dev-main", "symfony/twig-bundle": "^6.4", "symfony/webpack-encore-bundle": "^2.2", "symfony/security-bundle": "^6.4", - "tatevikgr/rest-api-client": "dev-dev" + "tatevikgr/rest-api-client": "dev-main" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/openapi.json b/openapi.json index 61d88bd..722bd8a 100644 --- a/openapi.json +++ b/openapi.json @@ -1333,6 +1333,270 @@ } } }, + "/api/v2/bounces": { + "get": { + "tags": [ + "bounces" + ], + "summary": "Gets a list of all bounces.", + "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production. Returns a JSON list of all bounces.", + "operationId": "98e6f5ee3a2c8cb61009583a938b22cb", + "parameters": [ + { + "name": "php-auth-pw", + "in": "header", + "description": "Session key obtained from login", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "after_id", + "in": "query", + "description": "Last id (starting from 0)", + "required": false, + "schema": { + "type": "integer", + "default": 1, + "minimum": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "Number of results per page", + "required": false, + "schema": { + "type": "integer", + "default": 25, + "maximum": 100, + "minimum": 1 + } + }, + { + "name": "status", + "in": "query", + "description": "Bounce status", + "required": false, + "schema": { + "type": "string", + "default": "unidentified bounce", + "maxLength": 100, + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BounceView" + } + } + } + } + }, + "403": { + "description": "Failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + } + } + } + } + } + } + }, + "/api/v2/bounces/{bounceId}": { + "delete": { + "tags": [ + "bounces" + ], + "summary": "Delete a bounce by its id", + "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production. Delete a bounce by its id.", + "operationId": "52d0665c9ee3e8d0276cfe65e43866f4", + "parameters": [ + { + "name": "php-auth-pw", + "in": "header", + "description": "Session key obtained from login", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "bounceId", + "in": "path", + "description": "Bounce id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Success" + }, + "403": { + "description": "Failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + } + } + } + }, + "404": { + "description": "Failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponse" + } + } + } + } + } + } + }, + "/api/v2/bounces/by/campaign": { + "get": { + "tags": [ + "bounces" + ], + "summary": "Gets a list of bounce counts by campaign.", + "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production. Returns a JSON list of bounce counts by campaign.", + "operationId": "a2de96a367d951e9047630b863fbc756", + "parameters": [ + { + "name": "php-auth-pw", + "in": "header", + "description": "Session key obtained from login", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "message_id": { + "type": "integer", + "example": 1 + }, + "subject": { + "type": "string", + "example": "System" + }, + "total_bounces": { + "type": "integer", + "example": 3 + } + }, + "type": "object" + } + } + } + } + }, + "403": { + "description": "Failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + } + } + } + } + } + } + }, + "/api/v2/bounces/by/subscriber": { + "get": { + "tags": [ + "bounces" + ], + "summary": "Gets a list of bounce counts by subscriber.", + "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production. Returns a JSON list of bounce counts by subscriber.", + "operationId": "e914619969016f6d3ec3da6b645b5ff3", + "parameters": [ + { + "name": "php-auth-pw", + "in": "header", + "description": "Session key obtained from login", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "subscriber_id": { + "type": "integer", + "example": 1 + }, + "email": { + "type": "string", + "example": "example@email.com" + }, + "confirmed": { + "type": "boolean", + "example": true + }, + "blacklisted": { + "type": "boolean", + "example": true + }, + "total_bounces": { + "type": "integer", + "example": 3 + } + }, + "type": "object" + } + } + } + } + }, + "403": { + "description": "Failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + } + } + } + } + } + } + }, "/api/v2/bounces/regex": { "get": { "tags": [ @@ -1382,9 +1646,9 @@ "tags": [ "bounces" ], - "summary": "Create or update a bounce regex", - "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production. Creates a new bounce regex or updates an existing one (matched by regex hash).", - "operationId": "91f09d8583ea3629b39c328464961dd8", + "summary": "Create a bounce regex", + "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production. Creates a new bounce regex.", + "operationId": "97db4cfc72576a22e0bcd6215fd35e51", "parameters": [ { "name": "php-auth-pw", @@ -1397,52 +1661,80 @@ } ], "requestBody": { - "description": "Create or update a bounce regex rule.", + "description": "Create bounce regex rule", "required": true, "content": { "application/json": { "schema": { - "required": [ - "regex" - ], - "properties": { - "regex": { - "type": "string", - "example": "/mailbox is full/i" - }, - "action": { - "type": "string", - "example": "delete", - "nullable": true - }, - "list_order": { - "type": "integer", - "example": 0, - "nullable": true - }, - "admin": { - "type": "integer", - "example": 1, - "nullable": true - }, - "comment": { - "type": "string", - "example": "Auto-generated", - "nullable": true - }, - "status": { - "type": "string", - "example": "active", - "nullable": true - } - }, - "type": "object" + "$ref": "#/components/schemas/BounceRegexRequest" } } } - }, + }, + "responses": { + "201": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BounceRegex" + } + } + } + }, + "403": { + "description": "Failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + } + } + } + }, + "422": { + "description": "Failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + } + } + } + }, + "/api/v2/bounces/regex/{ruleId}": { + "get": { + "tags": [ + "bounces" + ], + "summary": "Get a bounce regex by its ID", + "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production. Returns a bounce regex by its ID.", + "operationId": "babc8c57b673d026ea5fc6100320d4a6", + "parameters": [ + { + "name": "php-auth-pw", + "in": "header", + "description": "Session key obtained from login", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "ruleId", + "in": "path", + "description": "Regex ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], "responses": { - "201": { + "200": { "description": "Success", "content": { "application/json": { @@ -1462,27 +1754,25 @@ } } }, - "422": { + "404": { "description": "Failure", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ValidationErrorResponse" + "$ref": "#/components/schemas/NotFoundErrorResponse" } } } } } - } - }, - "/api/v2/bounces/regex/{regexHash}": { - "get": { + }, + "put": { "tags": [ "bounces" ], - "summary": "Get a bounce regex by its hash", - "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production. Returns a bounce regex by its hash.", - "operationId": "be73154c156c45440b3b3f47513d8e86", + "summary": "Update a bounce regex", + "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production. Updates an existing one.", + "operationId": "fefed1a3824116daedc42d65b0117230", "parameters": [ { "name": "php-auth-pw", @@ -1494,17 +1784,28 @@ } }, { - "name": "regexHash", + "name": "ruleId", "in": "path", - "description": "Regex hash", + "description": "regex rule ID", "required": true, "schema": { - "type": "string" + "type": "integer" } } ], + "requestBody": { + "description": "Update a bounce regex rule.", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BounceRegexRequest" + } + } + } + }, "responses": { - "200": { + "201": { "description": "Success", "content": { "application/json": { @@ -1533,6 +1834,16 @@ } } } + }, + "422": { + "description": "Failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } } } }, @@ -1540,9 +1851,9 @@ "tags": [ "bounces" ], - "summary": "Delete a bounce regex by its hash", - "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production. Delete a bounce regex by its hash.", - "operationId": "47b3de553732c5de46647709a42d2ced", + "summary": "Delete a bounce regex by its id", + "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production. Delete a bounce regex by its id.", + "operationId": "afe7121aa9c6652066ba8ca4e2c07fba", "parameters": [ { "name": "php-auth-pw", @@ -1554,7 +1865,7 @@ } }, { - "name": "regexHash", + "name": "ruleId", "in": "path", "description": "Regex hash", "required": true, @@ -2810,7 +3121,223 @@ } }, "403": { - "description": "Unauthorized", + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + } + } + } + }, + "404": { + "description": "Message or subscriber list not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundErrorResponse" + } + } + } + } + } + } + }, + "/api/v2/templates": { + "get": { + "tags": [ + "templates" + ], + "summary": "Gets a list of all templates.", + "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production. Returns a JSON list of all templates.", + "operationId": "087039999e07f0b831aca77cb1f8bb1c", + "parameters": [ + { + "name": "php-auth-pw", + "in": "header", + "description": "Session key obtained from login", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "after_id", + "in": "query", + "description": "Last id (starting from 0)", + "required": false, + "schema": { + "type": "integer", + "default": 1, + "minimum": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "Number of results per page", + "required": false, + "schema": { + "type": "integer", + "default": 25, + "maximum": 100, + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Template" + } + }, + "pagination": { + "$ref": "#/components/schemas/CursorPagination" + } + }, + "type": "object" + } + } + } + }, + "403": { + "description": "Failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "templates" + ], + "summary": "Create a new template.", + "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production. Returns a JSON response of created template.", + "operationId": "1b4a0dd67dd9c65cd62ca500b7837a1d", + "parameters": [ + { + "name": "php-auth-pw", + "in": "header", + "description": "Session key obtained from login", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Pass session credentials", + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/UpdateTemplateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Template" + } + } + } + } + }, + "403": { + "description": "Failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + } + } + } + }, + "422": { + "description": "Failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + } + } + } + }, + "/api/v2/templates/defaults": { + "get": { + "tags": [ + "templates" + ], + "summary": "Gets a list of all default templates.", + "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production. Returns a JSON list of all available default templates.", + "operationId": "bd28f733452097eca52a8f72549ffe92", + "parameters": [ + { + "name": "php-auth-pw", + "in": "header", + "description": "Session key obtained from login", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "key": { + "type": "string", + "example": "system" + }, + "name": { + "type": "string", + "example": "System" + }, + "description": { + "type": "string", + "example": "Default system email" + }, + "file": { + "type": "string", + "example": "system.html" + } + }, + "type": "object" + } + } + } + } + }, + "403": { + "description": "Failure", "content": { "application/json": { "schema": { @@ -2818,28 +3345,18 @@ } } } - }, - "404": { - "description": "Message or subscriber list not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundErrorResponse" - } - } - } } } } }, - "/api/v2/templates": { - "get": { + "/api/v2/templates/defaults/{key}": { + "post": { "tags": [ "templates" ], - "summary": "Gets a list of all templates.", - "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production. Returns a JSON list of all templates.", - "operationId": "087039999e07f0b831aca77cb1f8bb1c", + "summary": "Creates a template from a default template.", + "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production. Creates a new template from a default template key.", + "operationId": "67e657e5c80cc0c88a4cc43a6eb55f99", "parameters": [ { "name": "php-auth-pw", @@ -2851,47 +3368,26 @@ } }, { - "name": "after_id", - "in": "query", - "description": "Last id (starting from 0)", - "required": false, - "schema": { - "type": "integer", - "default": 1, - "minimum": 1 - } - }, - { - "name": "limit", - "in": "query", - "description": "Number of results per page", - "required": false, + "name": "key", + "in": "path", + "description": "Default template key", + "required": true, "schema": { - "type": "integer", - "default": 25, - "maximum": 100, - "minimum": 1 + "type": "string", + "enum": [ + "system", + "responsive" + ] } } ], "responses": { - "200": { + "201": { "description": "Success", "content": { "application/json": { "schema": { - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Template" - } - }, - "pagination": { - "$ref": "#/components/schemas/CursorPagination" - } - }, - "type": "object" + "$ref": "#/components/schemas/Template" } } } @@ -2907,14 +3403,16 @@ } } } - }, - "post": { + } + }, + "/api/v2/templates/{templateId}": { + "get": { "tags": [ "templates" ], - "summary": "Create a new template.", - "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production. Returns a JSON response of created template.", - "operationId": "1b4a0dd67dd9c65cd62ca500b7837a1d", + "summary": "Gets a templateI by id.", + "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production. Returns template by id.", + "operationId": "399ceead91ad931b306c585e3728027a", "parameters": [ { "name": "php-auth-pw", @@ -2924,29 +3422,24 @@ "schema": { "type": "string" } - } - ], - "requestBody": { - "description": "Pass session credentials", - "required": true, - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/CreateTemplateRequest" - } + }, + { + "name": "templateId", + "in": "path", + "description": "template ID", + "required": true, + "schema": { + "type": "string" } } - }, + ], "responses": { - "201": { + "200": { "description": "Success", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Template" - } + "$ref": "#/components/schemas/Template" } } } @@ -2961,27 +3454,25 @@ } } }, - "422": { + "404": { "description": "Failure", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ValidationErrorResponse" + "$ref": "#/components/schemas/NotFoundErrorResponse" } } } } } - } - }, - "/api/v2/templates/{templateId}": { - "get": { + }, + "put": { "tags": [ "templates" ], - "summary": "Gets a templateI by id.", - "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production. Returns template by id.", - "operationId": "399ceead91ad931b306c585e3728027a", + "summary": "Update template.", + "description": "🚧 **Status: Beta** – This method is under development. Avoid using in production. Returns a JSON response of updated template.", + "operationId": "a73b9801869083ca7a9739aed68d1b92", "parameters": [ { "name": "php-auth-pw", @@ -2991,24 +3482,29 @@ "schema": { "type": "string" } - }, - { - "name": "templateId", - "in": "path", - "description": "template ID", - "required": true, - "schema": { - "type": "string" - } } ], + "requestBody": { + "description": "Pass session credentials", + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/CreateTemplateRequest" + } + } + } + }, "responses": { - "200": { + "201": { "description": "Success", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Template" + "type": "array", + "items": { + "$ref": "#/components/schemas/Template" + } } } } @@ -3023,12 +3519,12 @@ } } }, - "404": { + "422": { "description": "Failure", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotFoundErrorResponse" + "$ref": "#/components/schemas/ValidationErrorResponse" } } } @@ -6802,6 +7298,40 @@ }, "type": "object" }, + "BounceRegexRequest": { + "required": [ + "regex", + "action", + "status" + ], + "properties": { + "regex": { + "type": "string", + "example": "/mailbox is full/i" + }, + "action": { + "type": "string", + "example": "delete", + "nullable": false + }, + "list_order": { + "type": "integer", + "example": 0, + "nullable": true + }, + "comment": { + "type": "string", + "example": "Auto-generated", + "nullable": true + }, + "status": { + "type": "string", + "example": "active", + "nullable": false + } + }, + "type": "object" + }, "CreateTemplateRequest": { "required": [ "title" @@ -6820,6 +7350,11 @@ "type": "string", "example": "[CONTENT]" }, + "list_order": { + "type": "integer", + "example": 10, + "nullable": true + }, "file": { "description": "Optional file upload for HTML content", "type": "string", @@ -7038,6 +7573,95 @@ }, "type": "object" }, + "UpdateTemplateRequest": { + "required": [ + "title" + ], + "properties": { + "title": { + "type": "string", + "example": "Newsletter Template" + }, + "content": { + "type": "string", + "example": "[CONTENT]", + "nullable": true + }, + "text": { + "type": "string", + "example": "[CONTENT]" + }, + "list_order": { + "type": "integer", + "example": 10, + "nullable": true + }, + "file": { + "description": "Optional file upload for HTML content", + "type": "string", + "format": "binary" + }, + "check_links": { + "description": "Check that all links have full URLs", + "type": "boolean", + "example": true + }, + "check_images": { + "description": "Check that all images have full URLs", + "type": "boolean", + "example": false + }, + "check_external_images": { + "description": "Check that all external images exist", + "type": "boolean", + "example": true + } + }, + "type": "object" + }, + "BounceView": { + "properties": { + "id": { + "type": "integer", + "example": 10 + }, + "status": { + "type": "string", + "example": "not processed", + "nullable": true + }, + "date": { + "type": "string", + "example": "2023-01-01T12:00:00Z", + "nullable": true + }, + "message_id": { + "type": "integer", + "example": 123 + }, + "message_subject": { + "type": "string", + "example": "Newsletter", + "nullable": true + }, + "subscriber_id": { + "type": "integer", + "example": 0, + "nullable": true + }, + "subscriber_email": { + "type": "string", + "example": "user@example.com", + "nullable": true + }, + "comment": { + "type": "string", + "example": "Auto-generated rule", + "nullable": true + } + }, + "type": "object" + }, "BounceRegex": { "properties": { "id": { @@ -7375,7 +7999,7 @@ "type": "string", "nullable": true }, - "order": { + "list_order": { "type": "integer", "nullable": true }, @@ -8179,4 +8803,4 @@ "description": "lists" } ] -} \ No newline at end of file +} diff --git a/package.json b/package.json index c03eed1..7d40e2c 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ }, "dependencies": { "@ckeditor/ckeditor5-vue": "^7.4.0", - "@tatevikgr/rest-api-client": "^2.1.4", + "@tatevikgr/rest-api-client": "^2.1.5", "apexcharts": "^5.10.4", "ckeditor5": "^48.0.0", "vue": "^3.5.16", diff --git a/src/DependencyInjection/PhpListFrontendExtension.php b/src/DependencyInjection/PhpListFrontendExtension.php index 120eca2..36c4348 100755 --- a/src/DependencyInjection/PhpListFrontendExtension.php +++ b/src/DependencyInjection/PhpListFrontendExtension.php @@ -55,31 +55,31 @@ public function prepend(ContainerBuilder $container): void ], ]); -// $container->prependExtensionConfig('security', [ -// 'providers' => [ -// 'in_memory' => ['memory' => null], -// ], -// 'firewalls' => [ -// 'api' => [ -// 'pattern' => '^/api/v2', -// 'security' => false, -// ], -// 'main' => [ -// 'lazy' => true, -// 'provider' => 'in_memory', -// 'pattern' => '^/', -// 'custom_authenticators' => [ -// 'PhpList\\WebFrontend\\Security\\SessionAuthenticator', -// ], -// 'entry_point' => 'PhpList\\WebFrontend\\Security\\SessionAuthenticator', -// ], -// ], -// 'access_control' => [ -// ['path' => '^/login', 'roles' => 'PUBLIC_ACCESS'], -// ['path' => '^/api/v2', 'roles' => 'PUBLIC_ACCESS'], -// ['path' => '^/', 'roles' => 'ROLE_ADMIN'], -// ], -// ]); + $container->prependExtensionConfig('security', [ + 'providers' => [ + 'in_memory' => ['memory' => null], + ], + 'firewalls' => [ + 'api' => [ + 'pattern' => '^/api/v2', + 'security' => false, + ], + 'main' => [ + 'lazy' => true, + 'provider' => 'in_memory', + 'pattern' => '^/', + 'custom_authenticators' => [ + 'PhpList\\WebFrontend\\Security\\SessionAuthenticator', + ], + 'entry_point' => 'PhpList\\WebFrontend\\Security\\SessionAuthenticator', + ], + ], + 'access_control' => [ + ['path' => '^/login', 'roles' => 'PUBLIC_ACCESS'], + ['path' => '^/api/v2', 'roles' => 'PUBLIC_ACCESS'], + ['path' => '^/', 'roles' => 'ROLE_ADMIN'], + ], + ]); } private function getBundlePath(): string diff --git a/yarn.lock b/yarn.lock index 936d9c8..87f7c20 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2119,10 +2119,10 @@ postcss "^8.5.6" tailwindcss "4.2.1" -"@tatevikgr/rest-api-client@^2.1.4": - version "2.1.4" - resolved "https://registry.yarnpkg.com/@tatevikgr/rest-api-client/-/rest-api-client-2.1.4.tgz#c698e4a59f5f5f48c0d70ec35b8695fe758bb587" - integrity sha512-vjALxeCX9lr1R1Ut3vGr6tBYfYhG+LD0jAFDqDIcruBSRzRwAub8Egok85EFq8nYcftCLPCVB7luR8XRARRr5Q== +"@tatevikgr/rest-api-client@^2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@tatevikgr/rest-api-client/-/rest-api-client-2.1.5.tgz#a6114af2b0e890074826d54280589877f6348c29" + integrity sha512-xWOisnfqJxfr01wlNFStvpXbEb0j9EQ6DyRQ+90h3wV6PdcD5AXGQBHNjsggZ70ou0/LImLJaWYVxdDCZFrZ0Q== dependencies: axios "^1.6.0" From 285114a066275874941b0c22f1155d8e1ee073af Mon Sep 17 00:00:00 2001 From: Tatevik Date: Thu, 30 Apr 2026 12:22:08 +0400 Subject: [PATCH 09/11] After review 0 --- assets/vue/api.js | 42 ++++++ .../vue/components/bounces/BounceOverview.vue | 124 +++++++++--------- assets/vue/components/bounces/BounceRules.vue | 4 +- .../subscribers/SubscriberDirectory.vue | 10 +- assets/vue/layouts/AdminLayout.vue | 4 +- .../Controller/BouncesControllerTest.php | 3 +- .../Controller/DashboardControllerTest.php | 3 +- .../Controller/ListsControllerTest.php | 3 +- 8 files changed, 114 insertions(+), 79 deletions(-) diff --git a/assets/vue/api.js b/assets/vue/api.js index 65fabaf..a78a60a 100644 --- a/assets/vue/api.js +++ b/assets/vue/api.js @@ -11,6 +11,27 @@ import { BouncesClient, } from '@tatevikgr/rest-api-client'; +const AUTHENTICATION_REDIRECT_PATH = '/login'; +let isAuthenticationRedirectInProgress = false; + +const isAuthenticationError = (error) => + error?.name === 'AuthenticationException' + || error?.status === 401 + || error?.response?.status === 401; + +const redirectToLogin = () => { + if (typeof window === 'undefined') { + return; + } + + if (window.location.pathname === AUTHENTICATION_REDIRECT_PATH || isAuthenticationRedirectInProgress) { + return; + } + + isAuthenticationRedirectInProgress = true; + window.location.href = AUTHENTICATION_REDIRECT_PATH; +}; + const appElement = document.getElementById('vue-app'); const apiToken = appElement?.dataset.apiToken; const apiBaseUrl = appElement?.dataset.apiBaseUrl; @@ -25,6 +46,17 @@ if (apiToken) { client.setSessionId(apiToken); } +client.axiosInstance?.interceptors?.response?.use( + (response) => response, + (error) => { + if (isAuthenticationError(error)) { + redirectToLogin(); + } + + return Promise.reject(error); + } +); + export const subscribersClient = new SubscribersClient(client); export const listClient = new ListClient(client); export const campaignClient = new CampaignClient(client); @@ -35,6 +67,16 @@ export const subscriberAttributesClient = new SubscriberAttributesClient(client) export const templateClient = new TemplatesClient(client); export const bouncesClient = new BouncesClient(client); +export const backendFetch = async (input, init = undefined) => { + const response = await fetch(input, init); + + if (response.status === 401) { + redirectToLogin(); + } + + return response; +}; + export const fetchAllLists = async ({ limit = 100, maxPages = 100 } = {}) => { const lists = []; let afterId = null; diff --git a/assets/vue/components/bounces/BounceOverview.vue b/assets/vue/components/bounces/BounceOverview.vue index c078e9a..c7fa353 100644 --- a/assets/vue/components/bounces/BounceOverview.vue +++ b/assets/vue/components/bounces/BounceOverview.vue @@ -98,7 +98,7 @@
- Showing {{ rangeStart }}-{{ rangeEnd }} of {{ allBounces.length }} + Page {{ currentPage }}