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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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"