+
+
+
+ ERPNext est en mute_emails et le scheduler est en pause : rien ne s'envoie.
+ Ces entrées sont seulement en file. Tu peux les inspecter et les supprimer en toute sécurité.
+
+
+
+
+ {{ s }} : {{ n }}
+
+ File vide
+
+
+
+
+
+
+
+
+
+
+
{{ r }}
+ —
+
+
+
+
+
+ {{ props.row.reference_doctype }} · {{ props.row.reference_name }}
+
+ —
+
+
+
+ {{ fmtDate(props.value) }}
+
+
+
+
+ {{ props.value }}
+ —
+
+
+
+
+
+ Supprimer ce courriel de la file
+
+
+
+
+
+
+
+
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).