Auth hardening: - payments: per-customer JWT authorization on dual-use /payments routes (balance/methods/invoice/checkout/setup/portal/toggle-ppa) via authorizeCustomer + PAYMENTS_AUTH=enforce; portal retains+sends magic-link JWT (sessionStorage) - hub: fail-closed Stripe webhook, /accept/doc-pdf IDOR gate, telephony field-name guard (SQLi), modem-bridge private-IP guard (SSRF), ALWAYS_ENFORCE expansion Leaked-credential cleanup (token already rotated in ERPNext): - de-hardcode ERPNext API token -> env in bulk_submit.py, import_items.py, apps/client/deploy.sh; placeholder in apps/ops/infra/nginx.conf (nginx injects) - ops prod build no longer bakes VITE_ERP_TOKEN (.env.production empty) - de-hardcode legacy DB password -> env in import_items.py - gitignore legacy migration PII exports (tsv/json) Conversations/UI: - floating (un-docked) conversation panel; full-width mailbox - per-message real sender from email headers; unified scroll; header spacing - campaign pre-send review (subject/from/channel), Gmail send channel, clone-as-draft Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
201 lines
17 KiB
JavaScript
201 lines
17 KiB
JavaScript
// ── Lecteur Gmail (compte de service + délégation domaine) — navigue/relève cc@ sans MFA ni mot de passe ──
|
|
// Auth = JWT RS256 signé avec la clé du compte de service (crypto natif) → access_token IMPERSONNÉ sur la boîte.
|
|
// AUCUNE dépendance npm (pas de googleapis) → pas de rebuild d'image. Clé dans /app/data/gmail_sa.json (ou env GMAIL_SA_JSON).
|
|
const fs = require('fs')
|
|
const crypto = require('crypto')
|
|
const { log, loadSeenSet, saveSeenSet } = require('./helpers')
|
|
|
|
const TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
|
const SCOPE = 'https://mail.google.com/' // accès COMPLET Gmail : lire + envoyer/répondre + supprimer/corbeille + libellés
|
|
function mailbox () { return (process.env.GMAIL_MAILBOX || 'cc@targointernet.com').trim() }
|
|
// From de TOUT envoi sortant (réponses, files, group-send). support@targo.ca est un alias « send-as » VÉRIFIÉ sur cc@ → Gmail l'autorise. Surchargeable par GMAIL_SEND_FROM.
|
|
function sendFrom () { return (process.env.GMAIL_SEND_FROM || 'support@targo.ca').trim() }
|
|
function loadSA () {
|
|
if (process.env.GMAIL_SA_JSON) { try { return JSON.parse(process.env.GMAIL_SA_JSON) } catch (e) {} }
|
|
try { return JSON.parse(fs.readFileSync('/app/data/gmail_sa.json', 'utf8')) } catch (e) { return null }
|
|
}
|
|
const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
|
|
const _toks = {} // cache token PAR boîte impersonée (cc@, factures@…)
|
|
async function getToken (mb) {
|
|
mb = mb || mailbox()
|
|
const c = _toks[mb]
|
|
if (c && Date.now() < c.exp - 60000) return c.tok
|
|
const sa = loadSA()
|
|
if (!sa || !sa.client_email || !sa.private_key) throw new Error('Clé compte de service Gmail absente (/app/data/gmail_sa.json)')
|
|
const iat = Math.floor(Date.now() / 1000); const exp = iat + 3600
|
|
const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
|
|
const claims = b64url(JSON.stringify({ iss: sa.client_email, sub: mb, scope: SCOPE, aud: TOKEN_URL, iat, exp }))
|
|
const signer = crypto.createSign('RSA-SHA256'); signer.update(header + '.' + claims)
|
|
const assertion = header + '.' + claims + '.' + b64url(signer.sign(sa.private_key))
|
|
const res = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=' + assertion })
|
|
const j = await res.json().catch(() => ({}))
|
|
if (!res.ok || !j.access_token) throw new Error('Token Gmail (' + mb + '): ' + (j.error_description || j.error || res.status))
|
|
_toks[mb] = { tok: j.access_token, exp: Date.now() + (j.expires_in || 3600) * 1000 }
|
|
return _toks[mb].tok
|
|
}
|
|
|
|
async function gapi (path, qs, mb) {
|
|
mb = mb || mailbox()
|
|
const tok = await getToken(mb)
|
|
const u = new URL('https://gmail.googleapis.com/gmail/v1/users/' + encodeURIComponent(mb) + path)
|
|
if (qs) for (const k in qs) if (qs[k] != null) u.searchParams.set(k, qs[k])
|
|
const res = await fetch(u, { headers: { Authorization: 'Bearer ' + tok } })
|
|
const j = await res.json().catch(() => ({}))
|
|
if (!res.ok) throw new Error('gmail ' + path + ': ' + ((j.error && j.error.message) || res.status))
|
|
return j
|
|
}
|
|
|
|
const hdr = (m, name) => { const h = ((m.payload && m.payload.headers) || []).find(x => x.name.toLowerCase() === name.toLowerCase()); return h ? h.value : '' }
|
|
const walkMime = (p, mime) => { if (!p) return ''; if (p.mimeType === mime && p.body && p.body.data) return Buffer.from(p.body.data, 'base64').toString('utf8'); if (p.parts) { for (const c of p.parts) { const r = walkMime(c, mime); if (r) return r } } return '' }
|
|
function decodeBody (payload) {
|
|
let txt = walkMime(payload, 'text/plain')
|
|
if (!txt) { const html = walkMime(payload, 'text/html'); if (html) txt = html.replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ').trim() }
|
|
return txt
|
|
}
|
|
// HTML brut du courriel (pour affichage WYSIWYG dans un iframe sandbox côté Ops). '' si le message est texte seul.
|
|
function decodeHtml (payload) { return walkMime(payload, 'text/html') || '' }
|
|
|
|
async function listMessages ({ q = 'newer_than:2d', max = 25, mb } = {}) { const j = await gapi('/messages', { q, maxResults: max }, mb); return j.messages || [] }
|
|
// Content-Id (sans <>) d'une part MIME — identifie les images inline référencées par « cid: » dans le HTML.
|
|
const partCid = (p) => { const h = ((p.headers || []).find(x => x.name && x.name.toLowerCase() === 'content-id')); return h ? String(h.value).replace(/^<|>$/g, '').trim() : '' }
|
|
// Liste les pièces jointes (filename + attachmentId + mimeType). EXCLUT les images inline (cid) : elles sont rendues dans le HTML, pas en pièce jointe.
|
|
function listAttachments (payload) {
|
|
const out = []
|
|
const walk = (p) => { if (!p) return; const inlineImg = partCid(p) && /^image\//i.test(p.mimeType || ''); if (!inlineImg && p.filename && p.body && p.body.attachmentId) out.push({ filename: p.filename, mimeType: p.mimeType || 'application/octet-stream', attachmentId: p.body.attachmentId, size: p.body.size || 0 }); if (p.parts) p.parts.forEach(walk) }
|
|
walk(payload)
|
|
return out
|
|
}
|
|
// Collecte les images inline (cid) : { cid, mimeType, attachmentId, data(base64url si inline), size }.
|
|
function collectInlineImages (payload) {
|
|
const out = []
|
|
const walk = (p) => { if (!p) return; const cid = partCid(p); if (cid && /^image\//i.test(p.mimeType || '') && p.body) out.push({ cid, mimeType: p.mimeType, attachmentId: p.body.attachmentId || null, data: p.body.data || null, size: p.body.size || 0 }); if (p.parts) p.parts.forEach(walk) }
|
|
walk(payload)
|
|
return out
|
|
}
|
|
// Remplace les <img src="cid:…"> par des data: URLs → l'image s'affiche dans l'inbox (sinon : image cassée). Cap 500 Ko/image (pas de bloat du stockage).
|
|
async function inlineCidImages (html, payload, msgId, mb) {
|
|
if (!html || !/cid:/i.test(html)) return html
|
|
let out = html
|
|
for (const im of collectInlineImages(payload)) {
|
|
if (im.size && im.size > 500000) continue
|
|
let b64 = im.data ? im.data.replace(/-/g, '+').replace(/_/g, '/') : null
|
|
if (!b64 && im.attachmentId) { try { b64 = await getAttachment(msgId, im.attachmentId, mb) } catch (e) { b64 = null } }
|
|
if (!b64) continue
|
|
out = out.replace(new RegExp('cid:' + im.cid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), 'data:' + (im.mimeType || 'image/png') + ';base64,' + b64)
|
|
}
|
|
return out
|
|
}
|
|
async function getMessage (id, mb) {
|
|
const m = await gapi('/messages/' + encodeURIComponent(id), { format: 'full' }, mb)
|
|
const html = await inlineCidImages(decodeHtml(m.payload), m.payload, m.id, mb)
|
|
return { id: m.id, threadId: m.threadId, from: hdr(m, 'From'), to: hdr(m, 'To'), subject: hdr(m, 'Subject'), date: hdr(m, 'Date'), messageId: hdr(m, 'Message-Id') || m.id, snippet: m.snippet || '', body: decodeBody(m.payload), html, attachments: listAttachments(m.payload) }
|
|
}
|
|
// Télécharge une pièce jointe → base64 standard (pour OCR vision). mb = boîte impersonée.
|
|
async function getAttachment (msgId, attId, mb) {
|
|
const j = await gapi('/messages/' + encodeURIComponent(msgId) + '/attachments/' + encodeURIComponent(attId), null, mb)
|
|
return (j.data || '').replace(/-/g, '+').replace(/_/g, '/') // base64url → base64
|
|
}
|
|
|
|
// ── Écriture : envoyer / répondre / supprimer ──
|
|
function encodeHeader (s) { return /[^\x00-\x7F]/.test(String(s)) ? '=?UTF-8?B?' + Buffer.from(String(s)).toString('base64') + '?=' : String(s) } // RFC 2047 pour sujets accentués
|
|
// Encode le NOM d'affichage d'une adresse « Nom <email> » en RFC 2047 (accents),
|
|
// en laissant <email> intact. Sans <>, renvoie tel quel (email nu).
|
|
function encodeAddress (addr) {
|
|
const m = String(addr || '').match(/^(.*?)\s*<([^>]+)>\s*$/)
|
|
if (!m) return String(addr || '')
|
|
const name = m[1].trim().replace(/^"(.*)"$/, '$1')
|
|
return (name ? encodeHeader(name) + ' ' : '') + '<' + m[2] + '>'
|
|
}
|
|
const htmlToText = (html) => String(html || '')
|
|
.replace(/<!--[\s\S]*?-->/g, ' ') // commentaires HTML, incl. conditionnels MSO (<o:PixelsPerInch>96</o:…>)
|
|
.replace(/<head[\s\S]*?<\/head>/gi, ' ') // <head> entier (style/meta/xml/title) — hors du texte lisible
|
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<br\s*\/?>(?=)/gi, '\n').replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim()
|
|
function buildRfc822 ({ to, subject, body, html, inReplyTo, references, from }) {
|
|
const h = ['From: ' + encodeAddress(from || sendFrom()), 'To: ' + encodeAddress(to), 'Subject: ' + encodeHeader(subject || ''), 'MIME-Version: 1.0']
|
|
if (inReplyTo) h.push('In-Reply-To: ' + inReplyTo)
|
|
if (references || inReplyTo) h.push('References: ' + (references || inReplyTo))
|
|
if (html) { // multipart/alternative : repli texte + HTML (déliverabilité + clients sans HTML)
|
|
const bnd = 'alt_' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)
|
|
h.push('Content-Type: multipart/alternative; boundary="' + bnd + '"')
|
|
const plain = body || htmlToText(html)
|
|
const parts = [
|
|
'--' + bnd, 'Content-Type: text/plain; charset="UTF-8"', 'Content-Transfer-Encoding: 8bit', '', plain, '',
|
|
'--' + bnd, 'Content-Type: text/html; charset="UTF-8"', 'Content-Transfer-Encoding: 8bit', '', html, '',
|
|
'--' + bnd + '--', '',
|
|
]
|
|
return h.join('\r\n') + '\r\n\r\n' + parts.join('\r\n')
|
|
}
|
|
h.push('Content-Type: text/plain; charset="UTF-8"', 'Content-Transfer-Encoding: 8bit')
|
|
return h.join('\r\n') + '\r\n\r\n' + (body || '')
|
|
}
|
|
// Envoie (ou répond, si threadId/inReplyTo) un courriel DEPUIS la boîte impersonnée. `html` => multipart/alternative.
|
|
async function sendMessage ({ to, subject, body, html, threadId, inReplyTo, references, from } = {}) {
|
|
if (!to) throw new Error('destinataire requis')
|
|
const tok = await getToken()
|
|
const payload = { raw: b64url(buildRfc822({ to, subject, body, html, inReplyTo, references, from })) }
|
|
if (threadId) payload.threadId = threadId
|
|
const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/' + encodeURIComponent(mailbox()) + '/messages/send', { method: 'POST', headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
|
|
const j = await res.json().catch(() => ({}))
|
|
if (!res.ok) throw new Error('gmail send: ' + ((j.error && j.error.message) || res.status))
|
|
return { ok: true, id: j.id, threadId: j.threadId }
|
|
}
|
|
async function trashMessage (id) { const tok = await getToken(); const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/' + encodeURIComponent(mailbox()) + '/messages/' + encodeURIComponent(id) + '/trash', { method: 'POST', headers: { Authorization: 'Bearer ' + tok } }); if (!res.ok) throw new Error('gmail trash: ' + res.status); return { ok: true, trashed: id } }
|
|
async function deleteMessage (id) { const tok = await getToken(); const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/' + encodeURIComponent(mailbox()) + '/messages/' + encodeURIComponent(id), { method: 'DELETE', headers: { Authorization: 'Bearer ' + tok } }); if (!(res.status === 204 || res.ok)) throw new Error('gmail delete: ' + res.status); return { ok: true, deleted: id } }
|
|
// Marque SPAM (label SPAM + retire INBOX/UNREAD) → Gmail apprend + sort de la boîte. Réversible (le message reste dans Spam).
|
|
async function markSpam (id) { const tok = await getToken(); const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/' + encodeURIComponent(mailbox()) + '/messages/' + encodeURIComponent(id) + '/modify', { method: 'POST', headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' }, body: JSON.stringify({ addLabelIds: ['SPAM'], removeLabelIds: ['INBOX', 'UNREAD'] }) }); if (!res.ok) throw new Error('gmail spam: ' + res.status); return { ok: true, spam: id } }
|
|
// Corbeille TOUT le fil (réversible 30 j) — pour « supprimer la conversation = sortir les courriels de la boîte ».
|
|
async function trashThread (threadId) { const tok = await getToken(); const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/' + encodeURIComponent(mailbox()) + '/threads/' + encodeURIComponent(threadId) + '/trash', { method: 'POST', headers: { Authorization: 'Bearer ' + tok } }); if (!res.ok) throw new Error('gmail trashThread: ' + res.status); return { ok: true, trashedThread: threadId } }
|
|
|
|
// Poll : nouveaux courriels → ingestEmail (dédup via seen). Opt-in GMAIL_INGEST=on.
|
|
const SEEN_FILE = '/app/data/gmail_seen.json'
|
|
const loadSeen = () => loadSeenSet(SEEN_FILE)
|
|
const saveSeen = (s) => saveSeenSet(SEEN_FILE, s)
|
|
async function poll () {
|
|
// Exclut les courriels adressés à factures@ : ils sont traités par le pipeline FACTURE FOURNISSEUR (pas des conversations).
|
|
const invTo = (process.env.GMAIL_INVOICE_TO || 'factures@targointernet.com').trim()
|
|
// `in:inbox` = on n'ingère QUE le courrier REÇU. Sans ça, la requête ramassait
|
|
// aussi le dossier Envoyés (ex. les 105 courriels d'une campagne partis depuis
|
|
// cette boîte) → fils parasites dans l'Inbox Ops. Les réponses des agents sont
|
|
// déjà ajoutées au fil à l'envoi, pas besoin de les ré-ingérer.
|
|
const ids = await listMessages({ q: 'in:inbox newer_than:3d' + (invTo ? ' -to:' + invTo : ''), max: 30 })
|
|
const seen = loadSeen(); let ingested = 0
|
|
const { ingestEmail } = require('./conversation')
|
|
for (const { id } of ids.slice().reverse()) {
|
|
if (seen.has(id)) continue
|
|
try { const m = await getMessage(id); await ingestEmail({ from: m.from, subject: m.subject, body: m.body || m.snippet, html: m.html, message_id: m.messageId, thread_id: m.threadId, gmail_id: m.id }); seen.add(id); ingested++ } catch (e) { log('gmail poll msg ' + id + ': ' + e.message) }
|
|
}
|
|
saveSeen(seen)
|
|
return { ok: true, scanned: ids.length, ingested }
|
|
}
|
|
|
|
let _timer = null
|
|
function start () {
|
|
if (_timer) return
|
|
if (String(process.env.GMAIL_INGEST || '').toLowerCase() === 'off') { log('Gmail ingest: OFF (GMAIL_INGEST=off)'); return }
|
|
if (!loadSA()) { log('Gmail ingest: clé absente → OFF (déposer /app/data/gmail_sa.json puis docker restart)'); return }
|
|
const ms = (Number(process.env.GMAIL_POLL_MIN) || 3) * 60000
|
|
_timer = setInterval(() => poll().catch(e => log('gmail poll error: ' + e.message)), ms)
|
|
log('Gmail ingest poller ON (' + (ms / 60000) + ' min) sur ' + mailbox())
|
|
poll().catch(e => log('gmail first poll: ' + e.message))
|
|
}
|
|
function stop () { if (_timer) { clearInterval(_timer); _timer = null } }
|
|
|
|
async function handle (req, res, method, p, url) {
|
|
const json = (c, o) => { res.writeHead(c, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)) }
|
|
try {
|
|
if (p === '/gmail/status' && method === 'GET') { const sa = loadSA(); return json(200, { configured: !!sa, sa_email: sa ? sa.client_email : null, mailbox: mailbox(), poller_on: !!_timer }) }
|
|
if (p === '/gmail/ping' && method === 'GET') { const mb = (url && url.searchParams.get('mailbox')) || mailbox(); await getToken(mb); const ids = await listMessages({ q: 'newer_than:30d', max: 1, mb }); return json(200, { ok: true, mailbox: mb, sample_found: ids.length }) }
|
|
if (p === '/gmail/poll' && method === 'POST') return json(200, await poll())
|
|
if (p === '/gmail/list' && method === 'GET') { const mb = (url && url.searchParams.get('mailbox')) || mailbox(); const q = (url && url.searchParams.get('q')) || 'newer_than:7d'; const ids = await listMessages({ q, max: 25, mb }); const out = []; for (const { id } of ids.slice(0, 20)) { try { out.push(await getMessage(id, mb)) } catch (e) {} } return json(200, { mailbox: mb, count: out.length, messages: out.map(m => ({ id: m.id, from: m.from, subject: m.subject, date: m.date, snippet: m.snippet, attachments: (m.attachments || []).map(a => a.filename) })) }) }
|
|
if (p === '/gmail/message' && method === 'GET') { const id = url && url.searchParams.get('id'); if (!id) return json(400, { error: 'id requis' }); return json(200, await getMessage(id)) }
|
|
if (p === '/gmail/send' && method === 'POST') { const b = await readBody(req); return json(200, await sendMessage(b || {})) }
|
|
if (p === '/gmail/trash' && method === 'POST') { const b = await readBody(req); if (!b || !b.id) return json(400, { error: 'id requis' }); return json(200, await trashMessage(b.id)) }
|
|
if (p === '/gmail/delete' && method === 'POST') { const b = await readBody(req); if (!b || !b.id) return json(400, { error: 'id requis' }); return json(200, await deleteMessage(b.id)) }
|
|
} catch (e) { return json(500, { error: e.message }) }
|
|
return json(404, { error: 'not found' })
|
|
}
|
|
function readBody (req) { return new Promise((resolve) => { let d = ''; req.on('data', c => { d += c; if (d.length > 5e6) req.destroy() }); req.on('end', () => { try { resolve(JSON.parse(d || '{}')) } catch (e) { resolve({}) } }); req.on('error', () => resolve({})) }) }
|
|
|
|
module.exports = { getToken, listMessages, getMessage, getAttachment, sendMessage, trashMessage, deleteMessage, markSpam, trashThread, poll, start, stop, handle, mailbox, sendFrom }
|