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 @@ + + + + + Bounces + + + + + + + ID + Date + Subscriber + Campaign + Status + Comment + + + + + Loading bounces... + + + {{ errorMessage }} + + + No bounces found. + + + #{{ bounce.id }} + {{ bounce.formattedDate }} + {{ bounce.email }} + {{ bounce.subject }} + + + {{ bounce.status }} + + + {{ bounce.comment }} + + + + + + + Loading bounces... + + + {{ errorMessage }} + + + No bounces found. + + + + #{{ bounce.id }} + + {{ bounce.status }} + + + {{ bounce.formattedDate }} + {{ bounce.email }} + {{ bounce.subject }} + {{ bounce.comment }} + + + + + + + Showing {{ rangeStart }}-{{ rangeEnd }} of {{ allBounces.length }} + + + + Previous + + + Next + + + + + + + + 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 @@ + + + + + Bounce Management + + Monitor, process and automate bounce handling across all campaigns + + + + + 0.8% Bounce Rate + + + + + + {{ tab.label }} + + {{ tab.count }} + + + + + + + + + + + + + Bounce Rules + + Rules are evaluated top-to-bottom. Drag to reorder priority. + + + + New Rule + + + + + Rule Builder + Start from a template + + + {{ template }} + + + + + + + # + Rule + Pattern + Action + Hits + Status + + + + {{ rule.priority }} + + {{ rule.name }} + {{ rule.type }} + + {{ rule.pattern }} + + {{ rule.action }} + + {{ rule.hits }} + + + {{ rule.enabled ? 'Enabled' : 'Disabled' }} + + + + + + {{ activeRulesCount }} of {{ bounceRules.length }} rules active + Export Rules + + + + + + + + {{ currentTabLabel }} + This panel matches the template navigation and can be expanded next. + + + + + + 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
#{{ bounce.id }}
{{ bounce.formattedDate }}
{{ bounce.email }}
{{ bounce.subject }}
{{ bounce.comment }}
+ Monitor, process and automate bounce handling across all campaigns +
+ Rules are evaluated top-to-bottom. Drag to reorder priority. +
Start from a template
{{ rule.name }}
{{ rule.type }}
{{ rule.pattern }}
This panel matches the template navigation and can be expanded next.
- Rules are evaluated top-to-bottom. Drag to reorder priority. -
#{{ subscriber.subscriberId }}
{{ subscriber.totalBounces }}
{{ subscriber.email }}
#{{ campaign.messageId }}
{{ campaign.totalBounces }}
{{ campaign.subject }}
{{ createError }}
+ {{ message }} +