feat(ops): Email Queue admin page (view/delete/purge ERPNext outbound)

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) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-02 17:29:33 -04:00
parent c4bf18fdcb
commit 21e2c846bf
7 changed files with 256 additions and 2 deletions

View File

@ -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()
}

View File

@ -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' },
]

View File

@ -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 }

View File

@ -0,0 +1,148 @@
<template>
<q-page padding>
<div class="row items-center q-mb-md">
<div class="text-h6 text-weight-bold">File de courriels (ERPNext)</div>
<q-space />
<q-select v-model="status" :options="statusOptions" emit-value map-options dense outlined
label="Statut" style="width:160px" class="q-mr-sm" @update:model-value="load" />
<q-btn flat dense icon="refresh" :loading="loading" @click="load" class="q-mr-sm" />
<q-btn color="negative" outline dense icon="delete_sweep" label="Purger « Not Sent »"
:disable="loading || !notSentCount" @click="confirmPurge" />
</div>
<q-banner dense rounded class="bg-blue-1 text-blue-9 q-mb-md">
<template #avatar><q-icon name="mark_email_read" /></template>
ERPNext est en <strong>mute_emails</strong> et le scheduler est en pause : <strong>rien ne s'envoie</strong>.
Ces entrées sont seulement en file. Tu peux les inspecter et les supprimer en toute sécurité.
</q-banner>
<div class="row q-gutter-sm q-mb-md">
<q-chip v-for="(n, s) in byStatus" :key="s" :color="statusColor(s)" text-color="white" dense>
{{ s }} : {{ n }}
</q-chip>
<q-chip v-if="!rows.length && loaded" color="grey-4" text-color="grey-8" dense>File vide</q-chip>
</div>
<q-table
v-if="loaded"
:rows="rows" :columns="columns" row-key="name"
flat bordered dense class="ops-table"
:pagination="{ rowsPerPage: 50, sortBy: 'creation', descending: true }"
:loading="loading"
>
<template #body-cell-status="props">
<q-td :props="props">
<q-chip :color="statusColor(props.value)" text-color="white" dense size="sm" :label="props.value" />
</q-td>
</template>
<template #body-cell-recipients="props">
<q-td :props="props">
<div v-for="(r, i) in (props.row.recipients || [])" :key="i" class="text-caption">{{ r }}</div>
<span v-if="!(props.row.recipients || []).length" class="text-grey-5"></span>
</q-td>
</template>
<template #body-cell-reference="props">
<q-td :props="props">
<span v-if="props.row.reference_doctype" class="text-caption">
{{ props.row.reference_doctype }} · {{ props.row.reference_name }}
</span>
<span v-else class="text-grey-5"></span>
</q-td>
</template>
<template #body-cell-creation="props">
<q-td :props="props">{{ fmtDate(props.value) }}</q-td>
</template>
<template #body-cell-error="props">
<q-td :props="props">
<q-icon v-if="props.value" name="error_outline" color="negative" />
<q-tooltip v-if="props.value" max-width="480px" class="text-body2">{{ props.value }}</q-tooltip>
<span v-else class="text-grey-5"></span>
</q-td>
</template>
<template #body-cell-actions="props">
<q-td :props="props">
<q-btn dense flat round size="sm" icon="delete" color="negative" @click="confirmDelete(props.row)">
<q-tooltip>Supprimer ce courriel de la file</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { listEmailQueue, deleteEmailQueueItem, purgeEmailQueue } from 'src/api/emailQueue'
const $q = useQuasar()
const rows = ref([])
const loading = ref(false)
const loaded = ref(false)
const status = ref('')
const byStatus = ref({})
const statusOptions = [
{ label: 'Tous', value: '' },
{ label: 'Not Sent', value: 'Not Sent' },
{ label: 'Sending', value: 'Sending' },
{ label: 'Sent', value: 'Sent' },
{ label: 'Error', value: 'Error' },
]
const columns = [
{ name: 'status', label: 'Statut', field: 'status', align: 'left', sortable: true },
{ name: 'recipients', label: 'Destinataire(s)', field: 'recipients', align: 'left' },
{ name: 'reference', label: 'Référence', field: 'reference_doctype', align: 'left' },
{ name: 'creation', label: 'Créé', field: 'creation', align: 'left', sortable: true },
{ name: 'error', label: 'Erreur', field: 'error', align: 'center' },
{ name: 'actions', label: '', field: 'name', align: 'center' },
]
const notSentCount = computed(() => byStatus.value['Not Sent'] || 0)
function statusColor (s) {
return { 'Not Sent': 'orange-8', Sending: 'blue-6', Sent: 'green-7', Error: 'negative', Expired: 'grey-6' }[s] || 'grey-7'
}
function fmtDate (iso) {
return iso ? new Date(iso.replace(' ', 'T')).toLocaleString('fr-CA', { dateStyle: 'medium', timeStyle: 'short' }) : ''
}
async function load () {
loading.value = true
try {
const data = await listEmailQueue(status.value)
rows.value = data.rows || []
byStatus.value = data.by_status || {}
loaded.value = true
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur : ' + e.message })
} finally {
loading.value = false
}
}
function confirmDelete (row) {
$q.dialog({
title: 'Supprimer',
message: `Supprimer ce courriel de la file (${(row.recipients || []).join(', ') || row.name}) ?`,
cancel: true, persistent: true,
}).onOk(async () => {
try { await deleteEmailQueueItem(row.name); $q.notify({ type: 'positive', message: 'Supprimé' }); load() }
catch (e) { $q.notify({ type: 'negative', message: e.message }) }
})
}
function confirmPurge () {
$q.dialog({
title: 'Purger',
message: `Supprimer les ${notSentCount.value} courriel(s) « Not Sent » de la file ?`,
cancel: true, persistent: true,
}).onOk(async () => {
try { const r = await purgeEmailQueue('Not Sent'); $q.notify({ type: 'positive', message: `${r.deleted} supprimé(s)` }); load() }
catch (e) { $q.notify({ type: 'negative', message: e.message }) }
})
}
onMounted(load)
</script>

View File

@ -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') },

View File

@ -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 }

View File

@ -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).