// ── 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(//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 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'), cc: hdr(m, 'Cc'), 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) } } // Récupère TOUT le fil Gmail (messages REÇUS *et* ENVOYÉS, dans l'ordre Gmail) → « re-synchroniser » une conversation : récupère les réponses envoyées hors hub (directement dans Gmail) et les courriels arrivés pendant une panne / sortis de la boîte (donc jamais vus par le poller `in:inbox newer_than`). async function getThread (threadId, mb) { const t = await gapi('/threads/' + encodeURIComponent(threadId), { format: 'full' }, mb) const out = [] for (const m of (t.messages || [])) { const html = await inlineCidImages(decodeHtml(m.payload), m.payload, m.id, mb) out.push({ id: m.id, threadId: m.threadId, from: hdr(m, 'From'), to: hdr(m, 'To'), cc: hdr(m, 'Cc'), 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) }) } return out } // 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 » en RFC 2047 (accents), // en laissant 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(//g, ' ') // commentaires HTML, incl. conditionnels MSO (96) .replace(//gi, ' ') // entier (style/meta/xml/title) — hors du texte lisible .replace(//gi, ' ').replace(/(?=)/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() // Encode une LISTE d'adresses séparées par des virgules (« a@x, Nom ») — chaque adresse en RFC 2047. function encodeAddressList (s) { return String(s || '').split(',').map(x => x.trim()).filter(Boolean).map(encodeAddress).join(', ') } // Entité MIME du CORPS (sans en-têtes d'enveloppe) : multipart/alternative si HTML, sinon texte simple. function bodyEntity ({ body, html }) { if (html) { // repli texte + HTML (déliverabilité + clients sans HTML) const bnd = 'alt_' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2) const plain = body || htmlToText(html) return [ 'Content-Type: multipart/alternative; boundary="' + bnd + '"', '', '--' + 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 + '--', ].join('\r\n') } return ['Content-Type: text/plain; charset="UTF-8"', 'Content-Transfer-Encoding: 8bit', '', (body || '')].join('\r\n') } // Une pièce jointe { filename, mime, content(base64) } → partie MIME pour multipart/mixed. function attachmentPart (a, bnd) { const fn = encodeHeader(String(a.filename || 'piece').replace(/["\r\n]/g, '')) const b64 = String(a.content || '').replace(/[^A-Za-z0-9+/=]/g, '').replace(/(.{76})/g, '$1\r\n') return [ '--' + bnd, 'Content-Type: ' + (a.mime || 'application/octet-stream') + '; name="' + fn + '"', 'Content-Transfer-Encoding: base64', 'Content-Disposition: attachment; filename="' + fn + '"', '', b64, ].join('\r\n') } function buildRfc822 ({ to, cc, bcc, subject, body, html, attachments, inReplyTo, references, from }) { const h = ['From: ' + encodeAddress(from || sendFrom()), 'To: ' + encodeAddressList(to)] if (cc) h.push('Cc: ' + encodeAddressList(cc)) if (bcc) h.push('Bcc: ' + encodeAddressList(bcc)) h.push('Subject: ' + encodeHeader(subject || ''), 'MIME-Version: 1.0') if (inReplyTo) h.push('In-Reply-To: ' + inReplyTo) if (references || inReplyTo) h.push('References: ' + (references || inReplyTo)) const atts = Array.isArray(attachments) ? attachments.filter(a => a && a.content) : [] if (atts.length) { // pièces jointes → multipart/mixed { corps, pièce(s) } const mix = 'mix_' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2) h.push('Content-Type: multipart/mixed; boundary="' + mix + '"') const parts = ['--' + mix, bodyEntity({ body, html }), ...atts.map(a => attachmentPart(a, mix)), '--' + mix + '--', ''] return h.join('\r\n') + '\r\n\r\n' + parts.join('\r\n') } return h.join('\r\n') + '\r\n' + bodyEntity({ body, html }) } // Envoie (ou répond, si threadId/inReplyTo) un courriel DEPUIS la boîte impersonnée. `html` => multipart/alternative. async function sendMessage ({ to, cc, bcc, subject, body, html, attachments, threadId, inReplyTo, references, from } = {}) { if (!to) throw new Error('destinataire requis') const tok = await getToken() const payload = { raw: b64url(buildRfc822({ to, cc, bcc, subject, body, html, attachments, 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:7d' + (invTo ? ' -to:' + invTo : ''), max: 40 }) 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, to: m.to, cc: m.cc, date: m.date, 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, getThread, getAttachment, sendMessage, trashMessage, deleteMessage, markSpam, trashThread, poll, start, stop, handle, mailbox, sendFrom }