diff --git a/.gitignore b/.gitignore
index 9538b98..4f64876 100755
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,4 @@
.env
/node_modules
/drivers/
+.codex
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..43e2e12 100644
--- a/assets/vue/api.js
+++ b/assets/vue/api.js
@@ -7,9 +7,29 @@ import {
SubscribersClient,
SubscriptionClient,
SubscriberAttributesClient,
- TemplatesClient
+ TemplatesClient,
+ 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;
@@ -18,12 +38,26 @@ if (!apiBaseUrl) {
console.error('API Base URL is not configured.');
}
-const client = new Client(apiBaseUrl || '');
+const client = new Client(apiBaseUrl || '', {
+ onAuthenticationError: redirectToLogin,
+ onAuthorizationError: redirectToLogin,
+});
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);
@@ -32,6 +66,17 @@ 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 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 = [];
diff --git a/assets/vue/components/bounces/BounceOverview.vue b/assets/vue/components/bounces/BounceOverview.vue
new file mode 100644
index 0000000..c7fa353
--- /dev/null
+++ b/assets/vue/components/bounces/BounceOverview.vue
@@ -0,0 +1,267 @@
+
+
+
+
+
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 }}
+
+
+
+
+
+
+ Page {{ currentPage }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/vue/components/bounces/BouncePer.vue b/assets/vue/components/bounces/BouncePer.vue
new file mode 100644
index 0000000..b487eb2
--- /dev/null
+++ b/assets/vue/components/bounces/BouncePer.vue
@@ -0,0 +1,228 @@
+
+
+
+
+
Bounces By Subscriber
+
+
+
+
+
+
+ | Subscriber |
+ Email |
+ Confirmed |
+ Blacklisted |
+ Total Bounces |
+
+
+
+
+ | Loading subscriber bounce data... |
+
+
+ | {{ subscriberErrorMessage }} |
+
+
+ | No subscriber bounce data found. |
+
+
+ | #{{ subscriber.subscriberId }} |
+ {{ subscriber.email }} |
+
+
+ {{ subscriber.confirmed ? 'Yes' : 'No' }}
+
+ |
+
+
+ {{ subscriber.blacklisted ? 'Yes' : 'No' }}
+
+ |
+ {{ subscriber.totalBounces }} |
+
+
+
+
+
+
+ Loading subscriber bounce data...
+
+
+ {{ subscriberErrorMessage }}
+
+
+ No subscriber bounce data found.
+
+
+
+
#{{ subscriber.subscriberId }}
+
{{ subscriber.totalBounces }}
+
+
{{ subscriber.email }}
+
+
+ Confirmed: {{ subscriber.confirmed ? 'Yes' : 'No' }}
+
+
+ Blacklisted: {{ subscriber.blacklisted ? 'Yes' : 'No' }}
+
+
+
+
+
+
+
+
+
+
Bounces By Campaign
+
+
+
+
+
+
+ | Campaign |
+ Subject |
+ Total Bounces |
+
+
+
+
+ | Loading campaign bounce data... |
+
+
+ | {{ campaignErrorMessage }} |
+
+
+ | No campaign bounce data found. |
+
+
+ | #{{ campaign.messageId }} |
+ {{ campaign.subject }} |
+ {{ campaign.totalBounces }} |
+
+
+
+
+
+
+ Loading campaign bounce data...
+
+
+ {{ campaignErrorMessage }}
+
+
+ No campaign bounce data found.
+
+
+
+
#{{ campaign.messageId }}
+
{{ campaign.totalBounces }}
+
+
{{ campaign.subject }}
+
+
+
+
+
+
+
+
diff --git a/assets/vue/components/bounces/BounceRules.vue b/assets/vue/components/bounces/BounceRules.vue
new file mode 100644
index 0000000..6889f6a
--- /dev/null
+++ b/assets/vue/components/bounces/BounceRules.vue
@@ -0,0 +1,401 @@
+
+
+
+
+
Bounce Rules
+
+ Rules are evaluated top-to-bottom. Drag to reorder priority.
+
+
+
+
+
+
+
+ #
+ Rule
+ Pattern
+ Action
+ Hits
+ Status
+
+
+
+
+
Rule - {{ rule.comment }}
+
Pattern - {{ rule.regex }}
+
Action - {{ rule.action }}
+
Hits - {{ rule.count }}
+
Status - {{ rule.status }}
+
+
+
+
{{ rule.list_order }}
+
+
{{ rule.regex }}
+
+ {{ rule.action }}
+
+
{{ rule.count }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/vue/components/bounces/BouncesActionsPanel.vue b/assets/vue/components/bounces/BouncesActionsPanel.vue
new file mode 100644
index 0000000..46ffba8
--- /dev/null
+++ b/assets/vue/components/bounces/BouncesActionsPanel.vue
@@ -0,0 +1,117 @@
+
+
+
+
+
Bounce Management
+
+ Monitor, process and automate bounce handling across all campaigns
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ currentTabLabel }}
+
This panel matches the template navigation and can be expanded next.
+
+
+
+
+
+
diff --git a/assets/vue/components/campaigns/CampaignDirectory.vue b/assets/vue/components/campaigns/CampaignDirectory.vue
index 91bedec..43ad4aa 100644
--- a/assets/vue/components/campaigns/CampaignDirectory.vue
+++ b/assets/vue/components/campaigns/CampaignDirectory.vue
@@ -499,54 +499,6 @@ const setActionFeedback = (campaignId, message, type = 'info') => {
actionFeedbackByCampaignId.value = { ...actionFeedbackByCampaignId.value, [campaignId]: { message, type } }
}
-const isAuthenticationError = (error) => error?.name === 'AuthenticationException' || error?.status === 401
-
-const buildCampaignPayload = (campaign, status = 'draft') => {
- const schedule = campaign?.messageSchedule || {}
- const format = campaign?.messageFormat || {}
- const options = campaign?.messageOptions || {}
-
- const payload = {
- content: {
- subject: campaign?.messageContent?.subject || `Copy of campaign #${campaign.id}`,
- text: campaign?.messageContent?.text || '',
- text_message: campaign?.messageContent?.textMessage || '',
- footer: campaign?.messageContent?.footer || ''
- },
- format: {
- html_formated: Boolean(format?.htmlFormated),
- send_format: format?.sendFormat || 'html',
- format_options: Array.isArray(format?.formatOptions) && format.formatOptions.length > 0
- ? format.formatOptions
- : [format?.sendFormat || 'html']
- },
- metadata: {
- status
- },
- schedule: {
- embargo: schedule?.embargo || new Date().toISOString()
- },
- options: {
- from_field: options?.fromField || '',
- to_field: options?.toField || '',
- reply_to: options?.replyTo || '',
- user_selection: options?.userSelection || ''
- }
- }
-
- if (campaign?.template?.id) payload.template_id = campaign.template.id
- if (schedule?.repeatInterval !== null && schedule?.repeatInterval !== undefined) {
- payload.schedule.repeat_interval = Number(schedule.repeatInterval)
- }
- if (schedule?.repeatUntil) payload.schedule.repeat_until = schedule.repeatUntil
- if (schedule?.requeueInterval !== null && schedule?.requeueInterval !== undefined) {
- payload.schedule.requeue_interval = Number(schedule.requeueInterval)
- }
- if (schedule?.requeueUntil) payload.schedule.requeue_until = schedule.requeueUntil
-
- return payload
-}
-
const handleRequeue = async (campaignId) => {
if (isActionLoading(campaignId)) return
setActionLoading(campaignId, true)
@@ -558,10 +510,6 @@ const handleRequeue = async (campaignId) => {
await fetchCampaigns()
} catch (error) {
console.error(`Failed to requeue campaign ${campaignId}:`, error)
- if (isAuthenticationError(error)) {
- window.location.href = '/login'
- return
- }
setActionFeedback(campaignId, error?.message || 'Failed to requeue campaign.', 'error')
} finally {
setActionLoading(campaignId, false)
@@ -579,10 +527,6 @@ const handleSuspend = async (campaignId) => {
await fetchCampaigns()
} catch (error) {
console.error(`Failed to suspend campaign ${campaignId}:`, error)
- if (isAuthenticationError(error)) {
- window.location.href = '/login'
- return
- }
setActionFeedback(campaignId, error?.message || 'Failed to suspend campaign.', 'error')
} finally {
setActionLoading(campaignId, false)
@@ -607,10 +551,6 @@ const handleDelete = async (campaign) => {
await fetchCampaigns()
} catch (error) {
console.error(`Failed to delete campaign ${campaign.id}:`, error)
- if (isAuthenticationError(error)) {
- window.location.href = '/login'
- return
- }
setActionFeedback(campaign.id, error?.message || 'Failed to delete campaign.', 'error')
} finally {
setActionLoading(campaign.id, false)
@@ -629,10 +569,6 @@ const handleView = async (campaignId) => {
selectedCampaign.value = await campaignClient.getCampaign(campaignId)
} catch (error) {
console.error(`Failed to load campaign ${campaignId}:`, error)
- if (isAuthenticationError(error)) {
- window.location.href = '/login'
- return
- }
viewErrorMessage.value = error?.message || 'Failed to load campaign.'
} finally {
isViewLoading.value = false
@@ -666,10 +602,6 @@ const handleResend = async (listIds) => {
closeViewModal()
} catch (error) {
console.error(`Failed to resend campaign ${campaignId}:`, error)
- if (isAuthenticationError(error)) {
- window.location.href = '/login'
- return
- }
resendErrorMessage.value = error?.message || 'Failed to resend campaign.'
} finally {
isResending.value = false
@@ -687,10 +619,6 @@ const handleCopyToDraft = async (campaignId) => {
setActionFeedback(campaignId, 'Created draft copy')
} catch (error) {
console.error(`Failed to copy campaign ${campaignId} to draft:`, error)
- if (isAuthenticationError(error)) {
- window.location.href = '/login'
- return
- }
setActionFeedback(campaignId, error?.message || 'Failed to create draft copy.', 'error')
} finally {
setActionLoading(campaignId, false)
@@ -911,10 +839,6 @@ const fetchCampaigns = async () => {
allCampaigns.value = campaigns
} catch (error) {
console.error('Failed to load campaigns:', error)
- if (error?.name === 'AuthenticationException' || error?.status === 401) {
- window.location.href = '/login'
- return
- }
errorMessage.value = 'Failed to load campaigns.'
allCampaigns.value = []
} finally {
diff --git a/assets/vue/components/subscribers/SubscriberDirectory.vue b/assets/vue/components/subscribers/SubscriberDirectory.vue
index a53da9e..7b3899e 100644
--- a/assets/vue/components/subscribers/SubscriberDirectory.vue
+++ b/assets/vue/components/subscribers/SubscriberDirectory.vue
@@ -95,7 +95,7 @@ import SubscriberModal from './SubscriberModal.vue'
import ImportResult from './ImportResult.vue'
import { inject, onMounted, ref } from 'vue'
import { subscriberFilters } from './subscriberFilters'
-import { subscribersClient } from '../../api'
+import { backendFetch, subscribersClient } from '../../api'
import ListSubscribersExportPanel from "../lists/ListSubscribersExportPanel.vue";
const initialSubscribers = inject('subscribers', [])
@@ -206,17 +206,11 @@ const fetchSubscribers = async (afterId = null) => {
}
try {
- const response = await fetch(url, {
+ const response = await backendFetch(url, {
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
signal
})
- if (response.status === 401) {
- const data = await response.json()
- window.location.href = data.redirect
- return
- }
-
const data = await response.json()
subscribers.value = data.items
diff --git a/assets/vue/layouts/AdminLayout.vue b/assets/vue/layouts/AdminLayout.vue
index f1d8aa1..7fbacd8 100644
--- a/assets/vue/layouts/AdminLayout.vue
+++ b/assets/vue/layouts/AdminLayout.vue
@@ -120,7 +120,7 @@ import { useSidebar } from "../composables/useSidebar";
import { onBeforeUnmount, onMounted, ref } from "vue";
import { RouterLink } from 'vue-router';
import { Requests } from "@tatevikgr/rest-api-client";
-import { subscribersClient, campaignClient } from "../api";
+import { backendFetch, subscribersClient, campaignClient } from "../api";
const { openSidebar } = useSidebar();
@@ -203,7 +203,7 @@ onMounted(async () => {
document.addEventListener('click', closeSearchResultsOnOutsideClick);
try {
- const response = await fetch('/admin-about', {
+ const response = await backendFetch('/admin-about', {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest'
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/assets/vue/views/CampaignEditView.vue b/assets/vue/views/CampaignEditView.vue
index a12affc..7c8efb9 100644
--- a/assets/vue/views/CampaignEditView.vue
+++ b/assets/vue/views/CampaignEditView.vue
@@ -491,8 +491,6 @@ const canQueueCampaign = computed(() =>
activeCampaignId.value > 0
)
-const isAuthenticationError = (error) => error?.name === 'AuthenticationException' || error?.status === 401
-
const validationFieldLabels = {
'format.sendFormat': 'Send format',
'schedule.repeatUntil': 'Stop sending after',
@@ -665,10 +663,6 @@ const loadCampaignData = async () => {
selectedListIds.value = [...linkedIds]
} catch (error) {
console.error('Failed to load campaign data for editing:', error)
- if (isAuthenticationError(error)) {
- window.location.href = '/login'
- return
- }
loadError.value = error?.message || 'Failed to load campaign data.'
} finally {
isLoading.value = false
@@ -828,10 +822,6 @@ const saveCampaign = async ({ advanceAfterSave = false } = {}) => {
saveSuccess.value = 'Campaign saved successfully.'
isSaved = true
} catch (error) {
- if (isAuthenticationError(error)) {
- window.location.href = '/login'
- return false
- }
const formattedErrors = formatValidationErrors(error)
if (formattedErrors.length > 0) {
saveErrors.value = formattedErrors
@@ -882,11 +872,6 @@ const queueCampaignToSend = async () => {
}
}
} catch (error) {
- if (isAuthenticationError(error)) {
- window.location.href = '/login'
- return
- }
-
const formattedErrors = formatValidationErrors(error)
if (formattedErrors.length > 0) {
saveErrors.value = formattedErrors
@@ -930,10 +915,6 @@ const sendTestCampaign = async () => {
await campaignClient.testSendCampaign(currentCampaignId, recipients)
saveSuccess.value = 'Test campaign sent successfully.'
} catch (error) {
- if (isAuthenticationError(error)) {
- window.location.href = '/login'
- return
- }
console.error('Failed to send test campaign:', error?.message ?? error)
saveError.value = error?.message?.slice(0, 100) || 'Failed to send test campaign.'
saveErrors.value = []
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 43f74e5..a0bd099 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.7",
"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..14fb3f2
--- /dev/null
+++ b/src/Controller/BouncesController.php
@@ -0,0 +1,31 @@
+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/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/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 %}
diff --git a/tests/Integration/Controller/BouncesControllerTest.php b/tests/Integration/Controller/BouncesControllerTest.php
new file mode 100644
index 0000000..d832cc0
--- /dev/null
+++ b/tests/Integration/Controller/BouncesControllerTest.php
@@ -0,0 +1,45 @@
+get('router');
+
+ self::assertSame('/bounces/', $router->generate('bounce_list'));
+ }
+
+ public function testBouncesPageRendersExpectedSpaPayload(): void
+ {
+ self::bootKernel();
+ /** @var BouncesController $controller */
+ $controller = static::getContainer()->get(BouncesController::class);
+ $apiBaseUrl = (string) static::getContainer()->getParameter('api_base_url');
+
+ $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(sprintf('data-api-base-url="%s"', $apiBaseUrl), $content);
+ }
+}
diff --git a/tests/Integration/Controller/DashboardControllerTest.php b/tests/Integration/Controller/DashboardControllerTest.php
index 0e27d15..b5c8061 100644
--- a/tests/Integration/Controller/DashboardControllerTest.php
+++ b/tests/Integration/Controller/DashboardControllerTest.php
@@ -27,6 +27,7 @@ public function testDashboardRouteIsRegistered(): void
public function testDashboardRendersSpaPayloadWithStats(): void
{
self::bootKernel();
+ $apiBaseUrl = (string) static::getContainer()->getParameter('api_base_url');
$statsClient = $this->createMock(StatisticsClient::class);
$statsClient->expects(self::once())
@@ -47,7 +48,7 @@ public function testDashboardRendersSpaPayloadWithStats(): void
self::assertSame(200, $response->getStatusCode());
self::assertStringContainsString('phpList - Dashboard', $content);
self::assertStringContainsString('data-api-token="integration-token"', $content);
- self::assertStringContainsString('data-api-base-url="http://api.phplist.local/"', $content);
+ self::assertStringContainsString(sprintf('data-api-base-url="%s"', $apiBaseUrl), $content);
self::assertStringContainsString('data-dashboard-stats=', $content);
self::assertStringContainsString('"recent_campaigns"', $content);
self::assertStringContainsString('Weekly Digest', $content);
diff --git a/tests/Integration/Controller/ListsControllerTest.php b/tests/Integration/Controller/ListsControllerTest.php
index 8817f2e..801453c 100644
--- a/tests/Integration/Controller/ListsControllerTest.php
+++ b/tests/Integration/Controller/ListsControllerTest.php
@@ -31,6 +31,7 @@ public function testListRoutesAreRegistered(string $routeName, array $parameters
public function testListsIndexRendersSpaForHtmlRequests(): void
{
self::bootKernel();
+ $apiBaseUrl = (string) static::getContainer()->getParameter('api_base_url');
$listClient = $this->createMock(ListClient::class);
$listClient->expects(self::never())->method('getLists');
@@ -45,7 +46,7 @@ public function testListsIndexRendersSpaForHtmlRequests(): void
self::assertSame(200, $response->getStatusCode());
self::assertStringContainsString('phpList - Lists', $content);
self::assertStringContainsString('data-api-token="integration-token"', $content);
- self::assertStringContainsString('data-api-base-url="http://api.phplist.local/"', $content);
+ self::assertStringContainsString(sprintf('data-api-base-url="%s"', $apiBaseUrl), $content);
}
public function testListsIndexReturnsJsonForJsonRequests(): void
diff --git a/yarn.lock b/yarn.lock
index 7e07567..19956d6 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.7":
+ version "2.1.7"
+ resolved "https://registry.yarnpkg.com/@tatevikgr/rest-api-client/-/rest-api-client-2.1.7.tgz#7765e4c3d95dc3854415be392ec76a5f985ec7e3"
+ integrity sha512-xHyRzRCHfJ0YhGJi3JB+Hib2+dkd3+JaioYarHgv/WSGLmzxgimYwUBGDm4KDiSP0CkgGi/TvUVuLU3suZokgg==
dependencies:
axios "^1.6.0"