/** * utils/graphMailer.js * Invia email tramite Microsoft Graph API (per Exchange con 2FA/OAuth2). * Gestisce automaticamente il token OAuth2 con cache e refresh. */ 'use strict'; let cachedToken = null; let tokenExpiresAt = 0; /** * Ottiene un access token OAuth2 da Microsoft (client credentials flow). * Il token viene cachato per circa 55 minuti per evitare richieste continue. */ async function getAccessToken() { const now = Date.now(); if (cachedToken && now < tokenExpiresAt) { return cachedToken; } const { AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET } = process.env; const url = `https://login.microsoftonline.com/${AZURE_TENANT_ID}/oauth2/v2.0/token`; const body = new URLSearchParams({ client_id: AZURE_CLIENT_ID, client_secret: AZURE_CLIENT_SECRET, scope: 'https://graph.microsoft.com/.default', grant_type: 'client_credentials', }); const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }); if (!res.ok) { const errText = await res.text(); throw new Error(`[Graph Auth] Errore ottenendo token OAuth2: ${res.status} ${errText}`); } const data = await res.json(); cachedToken = data.access_token; // Scade in data.expires_in secondi, refresh 5 minuti prima tokenExpiresAt = now + (data.expires_in - 300) * 1000; return cachedToken; } /** * Invia una email tramite Microsoft Graph API. * * @param {Object} options * @param {string[]} options.to - Destinatari (array di email) * @param {string[]} [options.cc] - CC (array di email) * @param {string[]} [options.bcc] - BCC (array di email) * @param {string} options.subject - Oggetto email * @param {string} options.bodyHtml - Corpo HTML * @param {Array} [options.attachments] - [{ filename, content (base64), contentType }] * @param {Array} [options.inlineImages] - [{ cid, content (base64), contentType }] * @returns {Promise} */ async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments = [], inlineImages = [], inReplyTo, references, messageId }) { const sender = process.env.AZURE_MAIL_SENDER; if (!sender) throw new Error('AZURE_MAIL_SENDER non configurato nel .env'); const token = await getAccessToken(); const toRecipients = to.map(addr => ({ emailAddress: { address: addr } })); const ccRecipients = cc.map(addr => ({ emailAddress: { address: addr } })); const bccRecipients = bcc.map(addr => ({ emailAddress: { address: addr } })); // Costruisce gli allegati (file + immagini inline) const allAttachments = [ ...attachments.map(a => ({ '@odata.type': '#microsoft.graph.fileAttachment', name: a.filename, contentType: a.contentType || 'application/octet-stream', contentBytes: a.content, // già base64 })), ...inlineImages.map(img => ({ '@odata.type': '#microsoft.graph.fileAttachment', name: img.cid, contentId: img.cid, contentType: img.contentType || 'image/png', contentBytes: img.content, // già base64 isInline: true, })), ]; const headers = []; if (inReplyTo) { headers.push({ name: 'In-Reply-To', value: inReplyTo }); } if (references) { headers.push({ name: 'References', value: references }); } const payload = { message: { subject, body: { contentType: 'HTML', content: bodyHtml, }, toRecipients, ccRecipients, bccRecipients, attachments: allAttachments, internetMessageHeaders: headers.length ? headers : undefined, }, saveToSentItems: true, }; const url = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(sender)}/sendMail`; const res = await fetch(url, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }); if (res.status === 202) { let actualMessageId = messageId; try { // Wait 1.5 seconds for Exchange to process and place it in Sent Items await new Promise(resolve => setTimeout(resolve, 1500)); const searchUrl = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(sender)}/mailFolders/sentItems/messages?$filter=subject eq '${subject.replace(/'/g, "''")}'&$top=1&$select=internetMessageId`; const searchRes = await fetch(searchUrl, { headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); if (searchRes.ok) { const searchData = await searchRes.json(); if (searchData.value && searchData.value.length > 0) { actualMessageId = searchData.value[0].internetMessageId; } } } catch (searchErr) { console.warn('[Graph Mailer] Failed to retrieve actual InternetMessageId from Sent Items:', searchErr.message); } return { internetMessageId: actualMessageId }; } const errText = await res.text(); throw new Error(`[Graph Mail] Errore invio email: ${res.status} ${errText}`); } module.exports = { sendMail };