Accumulated work on the dispatch/legacy-writeback branch: - Communications UI: CommunicationsPage, ConversationFullPage, DepartmentBoard, PipelineBoard, ReaderStack, Orchestrator/NewTicket/ServiceStatus/Outbox dialogs; hub gmail.js, ticket-collab.js, outbox.js, coupon-triage.js, client-diag.js. - Billing/sync mirror (F→ERPNext): legacy-payments.js, legacy-sync.js, sync-orchestrator.js, supplier-invoices.js, municipality.js + incremental migration scripts; LegacySyncPage, SupplierInvoices + negative-billing / terminated-active reports. - Roster/campaigns/network/voice: roster + roster-assistant, campaigns, giftbit, olt-snmp, traccar, twilio, vision, tech-absence-sms, ai/agent/config/helpers, legacy-dispatch-sync; ops PlanificationPage, RapportsPage, Settings, Tickets, ClientDetail updates. - docs/ PLATFORM_GUIDE + UI_AND_OPTIMIZATION; .gitignore __pycache__. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
75 lines
5.4 KiB
JavaScript
75 lines
5.4 KiB
JavaScript
'use strict'
|
|
// ── Tampon d'envoi sortant (« undo send ») pour les courriels MULTI-DESTINATAIRES (notifs de file) ──────────
|
|
// But : avant qu'un fan-out parte, le retenir N secondes, l'AFFICHER (destinataires + sujet + compte à rebours)
|
|
// dans un panneau OPS, et permettre d'ANNULER. Filet anti-spam (voir [[feedback_inbox_queue_notify]]).
|
|
// Les réponses agent à 1 destinataire ne passent PAS ici (envoi immédiat, aucun risque). HOLD=0 → envoi direct.
|
|
const fs = require('fs')
|
|
const { log } = require('./helpers')
|
|
const sse = require('./sse')
|
|
|
|
const STORE = '/app/data/outbox.json'
|
|
const HOLD_MS = Math.max(0, (Number(process.env.OUTBOX_HOLD_SEC) || 20) * 1000)
|
|
const pending = new Map() // id -> { id, to, subject, sendAt, opts, meta, status, error? }
|
|
const timers = new Map()
|
|
let seq = 0
|
|
|
|
function persist () { try { fs.writeFileSync(STORE + '.tmp', JSON.stringify([...pending.values()])); fs.renameSync(STORE + '.tmp', STORE) } catch (e) { log('outbox persist: ' + e.message) } }
|
|
function recipientsOf (to) { return String(to || '').split(',').map(s => s.trim()).filter(Boolean) }
|
|
function view (e) { return { id: e.id, subject: e.subject, recipients: recipientsOf(e.to), count: recipientsOf(e.to).length, sendAt: e.sendAt, status: e.status || 'pending', error: e.error || null, kind: (e.meta && e.meta.kind) || 'email', label: (e.meta && e.meta.label) || '' } }
|
|
function list () { return [...pending.values()].sort((a, b) => a.sendAt - b.sendAt).map(view) }
|
|
function broadcast () { try { sse.broadcast('outbox', 'outbox', { pending: list(), holdSec: HOLD_MS / 1000 }) } catch (e) { /* */ } }
|
|
|
|
async function doSend (e) {
|
|
const gmail = require('./gmail')
|
|
const r = await gmail.sendMessage(e.opts)
|
|
if (e.meta && e.meta.convToken) { try { require('./conversation').onOutboxSent(e.meta.convToken, r, e.meta) } catch (x) { /* */ } }
|
|
return r
|
|
}
|
|
async function flush (id) {
|
|
const e = pending.get(id); if (!e || e.status === 'sending') return
|
|
e.status = 'sending'; clearTimeout(timers.get(id)); timers.delete(id)
|
|
try {
|
|
await doSend(e)
|
|
pending.delete(id); persist(); broadcast()
|
|
log('outbox SENT ' + id + ' → ' + e.to + ' [' + ((e.meta && e.meta.kind) || 'email') + ']')
|
|
} catch (err) {
|
|
e.status = 'failed'; e.error = err.message; persist(); broadcast() // reste visible (rouge) → Réessayer / Rejeter
|
|
log('outbox FAILED ' + id + ': ' + err.message)
|
|
}
|
|
}
|
|
function arm (e) { const t = setTimeout(() => flush(e.id).catch(() => {}), Math.max(0, e.sendAt - Date.now())); timers.set(e.id, t) }
|
|
|
|
// Met en file un envoi retenu. Renvoie une promesse qui résout TÔT (l'envoi réel se fait au flush).
|
|
function enqueue (opts, meta = {}) {
|
|
if (!opts || !opts.to) return Promise.resolve({ ok: false, error: 'destinataire requis' })
|
|
if (HOLD_MS === 0) return doSend({ opts, meta }).then(r => ({ ok: true, id: r && r.id, sent: true })).catch(e => ({ ok: false, error: e.message }))
|
|
const id = 'ob_' + Date.now().toString(36) + '_' + (++seq)
|
|
const e = { id, to: opts.to, subject: opts.subject || '', sendAt: Date.now() + HOLD_MS, opts, meta, status: 'pending' }
|
|
pending.set(id, e); persist(); arm(e); broadcast()
|
|
log('outbox HELD ' + id + ' → ' + recipientsOf(opts.to).length + ' dest. [' + (meta.kind || 'email') + '] « ' + e.subject.slice(0, 50) + ' »')
|
|
return Promise.resolve({ ok: true, queued: true, id, sendAt: e.sendAt })
|
|
}
|
|
function cancel (id) { const e = pending.get(id); if (!e) return { ok: false, error: 'introuvable' }; clearTimeout(timers.get(id)); timers.delete(id); pending.delete(id); persist(); broadcast(); log('outbox CANCEL ' + id); return { ok: true, cancelled: id } }
|
|
function cancelAll () { const ids = [...pending.keys()]; for (const id of ids) { clearTimeout(timers.get(id)); timers.delete(id) } pending.clear(); persist(); broadcast(); log('outbox CANCEL-ALL (' + ids.length + ')'); return { ok: true, cancelled: ids.length } }
|
|
async function sendNow (id) { const e = pending.get(id); if (!e) return { ok: false, error: 'introuvable' }; if (e.status === 'failed') e.status = 'pending'; e.sendAt = Date.now(); await flush(id); const still = pending.get(id); return { ok: !still || still.status !== 'failed', id, error: still && still.error } }
|
|
|
|
function startup () {
|
|
try { for (const e of JSON.parse(fs.readFileSync(STORE, 'utf8'))) pending.set(e.id, e) } catch (e) { return }
|
|
let armed = 0
|
|
for (const e of pending.values()) { if (e.status === 'failed') continue; arm(e); armed++ } // les en attente repartent (sendAt passé → flush quasi immédiat)
|
|
if (pending.size) log('outbox: ' + pending.size + ' en file rechargés (' + armed + ' réarmés)')
|
|
broadcast()
|
|
}
|
|
|
|
async function handle (req, res, method, p) {
|
|
const json = (c, o) => { res.writeHead(c, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)) }
|
|
const body = () => new Promise(r => { let d = ''; req.on('data', c => { d += c }); req.on('end', () => { try { r(JSON.parse(d || '{}')) } catch (e) { r({}) } }); req.on('error', () => r({})) })
|
|
if (p === '/outbox' && method === 'GET') return json(200, { pending: list(), holdSec: HOLD_MS / 1000 })
|
|
if (p === '/outbox/cancel' && method === 'POST') { const b = await body(); return json(200, cancel(b.id)) }
|
|
if (p === '/outbox/cancel-all' && method === 'POST') return json(200, cancelAll())
|
|
if (p === '/outbox/send-now' && method === 'POST') { const b = await body(); return json(200, await sendNow(b.id)) }
|
|
return json(404, { error: 'not found' })
|
|
}
|
|
|
|
module.exports = { enqueue, cancel, cancelAll, sendNow, list, startup, handle }
|