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>
75 lines
3.2 KiB
JavaScript
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 }
|