From 21e2c846bff73eba53b94f8cf9f3ff255d5ee900 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Tue, 2 Jun 2026 17:29:33 -0400 Subject: [PATCH] feat(ops): Email Queue admin page (view/delete/purge ERPNext outbound) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces ERPNext's Email Queue in Ops (nav « File courriels ») so ops can see what's queued — important now that mute_emails=1 + scheduler paused mean nothing flushes — and delete/purge stale entries without the ERPNext desk. - hub lib/email-queue.js: GET list (by status, recipients read from each row's full doc since ERPNext ignores fields on child-doctype REST), DELETE :name, POST /purge {status}. Wired in server.js. - ops: api/emailQueue.js + EmailQueuePage.vue (status filter, recipients, reference, error tooltip, per-row delete + « Purger Not Sent »), route + nav. Verified live: 13 'Not Sent' (old internal test emails, no invoice refs). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/ops/src/api/emailQueue.js | 28 +++++ apps/ops/src/config/nav.js | 1 + apps/ops/src/layouts/MainLayout.vue | 4 +- apps/ops/src/pages/EmailQueuePage.vue | 148 ++++++++++++++++++++++++++ apps/ops/src/router/index.js | 1 + services/targo-hub/lib/email-queue.js | 74 +++++++++++++ services/targo-hub/server.js | 2 + 7 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 apps/ops/src/api/emailQueue.js create mode 100644 apps/ops/src/pages/EmailQueuePage.vue create mode 100644 services/targo-hub/lib/email-queue.js diff --git a/apps/ops/src/api/emailQueue.js b/apps/ops/src/api/emailQueue.js new file mode 100644 index 0000000..b8b5cf1 --- /dev/null +++ b/apps/ops/src/api/emailQueue.js @@ -0,0 +1,28 @@ +/** + * Email Queue admin API — calls targo-hub /email-queue/* (proxies ERPNext). + * Lets ops see + delete outbound emails sitting in ERPNext's queue. + */ +import { HUB_URL as HUB } from 'src/config/hub' + +export async function listEmailQueue (status = '') { + const qs = status ? `?status=${encodeURIComponent(status)}` : '' + const r = await fetch(HUB + '/email-queue' + qs) + if (!r.ok) throw new Error('Email queue API: ' + r.status) + return r.json() +} + +export async function deleteEmailQueueItem (name) { + const r = await fetch(HUB + '/email-queue/' + encodeURIComponent(name), { method: 'DELETE' }) + if (!r.ok) throw new Error('Suppression échouée: ' + r.status) + return r.json() +} + +export async function purgeEmailQueue (status = 'Not Sent') { + const r = await fetch(HUB + '/email-queue/purge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }), + }) + if (!r.ok) throw new Error('Purge échouée: ' + r.status) + return r.json() +} diff --git a/apps/ops/src/config/nav.js b/apps/ops/src/config/nav.js index 137b376..119c5a9 100644 --- a/apps/ops/src/config/nav.js +++ b/apps/ops/src/config/nav.js @@ -8,6 +8,7 @@ export const navItems = [ { path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users' }, { path: '/rapports', icon: 'BarChart3', label: 'Rapports', requires: 'view_dashboard_kpi' }, { path: '/campaigns', icon: 'Gift', label: 'Campagnes', requires: 'manage_users' }, + { path: '/email-queue', icon: 'Mail', label: 'File courriels', requires: 'view_settings' }, { path: '/settings', icon: 'Settings', label: 'Paramètres', requires: 'view_settings' }, ] diff --git a/apps/ops/src/layouts/MainLayout.vue b/apps/ops/src/layouts/MainLayout.vue index c909755..f9b1609 100644 --- a/apps/ops/src/layouts/MainLayout.vue +++ b/apps/ops/src/layouts/MainLayout.vue @@ -123,13 +123,13 @@ import { listDocs } from 'src/api/erp' import { navItems as allNavItems } from 'src/config/nav' import { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, - Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, + Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, } from 'lucide-vue-next' import ConversationPanel from 'src/components/shared/ConversationPanel.vue' import { useConversations } from 'src/composables/useConversations' import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue' -const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose } +const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail } const { panelOpen, activeCount: convCount } = useConversations() function toggleConvPanel () { panelOpen.value = !panelOpen.value } diff --git a/apps/ops/src/pages/EmailQueuePage.vue b/apps/ops/src/pages/EmailQueuePage.vue new file mode 100644 index 0000000..410fa4e --- /dev/null +++ b/apps/ops/src/pages/EmailQueuePage.vue @@ -0,0 +1,148 @@ + + + diff --git a/apps/ops/src/router/index.js b/apps/ops/src/router/index.js index 0b32f05..28d5955 100644 --- a/apps/ops/src/router/index.js +++ b/apps/ops/src/router/index.js @@ -35,6 +35,7 @@ const routes = [ { path: 'rapports/internet-cher', component: () => import('src/pages/ReportInternetCherPage.vue') }, { path: 'ocr', component: () => import('src/pages/OcrPage.vue') }, { path: 'settings', component: () => import('src/pages/SettingsPage.vue') }, + { path: 'email-queue', component: () => import('src/pages/EmailQueuePage.vue') }, { path: 'telephony', component: () => import('src/pages/TelephonyPage.vue') }, { path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') }, { path: 'agent-flows', component: () => import('src/pages/AgentFlowsPage.vue') }, diff --git a/services/targo-hub/lib/email-queue.js b/services/targo-hub/lib/email-queue.js new file mode 100644 index 0000000..a92e6cc --- /dev/null +++ b/services/targo-hub/lib/email-queue.js @@ -0,0 +1,74 @@ +'use strict' +/** + * email-queue.js — admin view of ERPNext's outbound Email Queue. + * + * During the migration, ERPNext email is globally muted (mute_emails=1) and the + * scheduler is paused, so nothing flushes. This gives ops a window to SEE what + * is queued (sender, recipients, reference doc, status, error) and DELETE/PURGE + * stale entries — without opening the ERPNext desk. + * + * Backed by the ERPNext REST API (same token the hub already uses). + * + * Routes: + * GET /email-queue?status=Not%20Sent&limit=200 → { rows, count } + * DELETE /email-queue/:name → { ok, deleted } + * POST /email-queue/purge { status } → { ok, deleted } + */ +const { json, parseBody, erpFetch } = require('./helpers') + +async function listQueue (status, limit) { + const params = new URLSearchParams({ + fields: JSON.stringify(['name', 'status', 'sender', 'reference_doctype', 'reference_name', 'creation', 'error', 'retry']), + order_by: 'creation desc', + limit_page_length: String(Math.min(500, limit || 200)), + }) + if (status) params.set('filters', JSON.stringify([['status', '=', status]])) + const res = await erpFetch('/api/resource/Email Queue?' + params) + if (res.status !== 200) throw new Error('ERP list failed: ' + res.status) + const rows = res.data?.data || [] + // ERPNext ignores `fields` when listing a child doctype via REST (returns + // only `name`), so read each row's recipients from its full doc instead. + // Capped to keep the call snappy on a large queue. + const CAP = 60 + await Promise.all(rows.slice(0, CAP).map(async (r) => { + try { + const d = await erpFetch('/api/resource/Email Queue/' + encodeURIComponent(r.name)) + r.recipients = (d.data?.data?.recipients || []).map(x => x.recipient).filter(Boolean) + } catch { r.recipients = [] } + })) + return rows +} + +async function deleteOne (name) { + const r = await erpFetch('/api/resource/Email Queue/' + encodeURIComponent(name), { method: 'DELETE' }) + return r.status === 202 || r.status === 200 +} + +async function handle (req, res, method, path, url) { + // GET /email-queue + if (path === '/email-queue' && method === 'GET') { + const rows = await listQueue(url.searchParams.get('status') || '', parseInt(url.searchParams.get('limit') || '200', 10)) + const byStatus = {} + for (const r of rows) byStatus[r.status] = (byStatus[r.status] || 0) + 1 + return json(res, 200, { rows, count: rows.length, by_status: byStatus }) + } + // POST /email-queue/purge { status } — checked before the generic :name route + if (path === '/email-queue/purge' && method === 'POST') { + const body = await parseBody(req) + const status = body.status || 'Not Sent' + const rows = await listQueue(status, 500) + let deleted = 0 + for (const r of rows) { if (await deleteOne(r.name)) deleted++ } + return json(res, 200, { ok: true, deleted, status }) + } + // DELETE /email-queue/:name + const m = path.match(/^\/email-queue\/(.+)$/) + if (m && method === 'DELETE') { + const name = decodeURIComponent(m[1]) + if (await deleteOne(name)) return json(res, 200, { ok: true, deleted: name }) + return json(res, 500, { error: 'delete failed', name }) + } + return json(res, 404, { error: 'Not found' }) +} + +module.exports = { handle } diff --git a/services/targo-hub/server.js b/services/targo-hub/server.js index d239f9f..c5375b2 100644 --- a/services/targo-hub/server.js +++ b/services/targo-hub/server.js @@ -124,6 +124,8 @@ const server = http.createServer(async (req, res) => { if (path.startsWith('/reports')) return require('./lib/reports').handle(req, res, method, path, url) // Per-address competitor/provider lookup via Québec IHV open data (ADR+FRN). if (path.startsWith('/serviceability')) return require('./lib/serviceability').handle(req, res, method, path) + // Admin view of ERPNext outbound Email Queue (view/delete/purge). + if (path.startsWith('/email-queue')) return require('./lib/email-queue').handle(req, res, method, path, url) if (path.startsWith('/campaigns')) return require('./lib/campaigns').handle(req, res, method, path) // Gift redirect wrapper — short public URLs in campaign emails that // 302 to the underlying Giftbit shortlink (subject to our expiry/revoke).