gigafibre-fsm/services/targo-hub/lib/email-queue.js
louispaulb 21e2c846bf 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>
2026-06-02 17:29:33 -04:00

75 lines
3.2 KiB
JavaScript

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