diff --git a/.env.example b/.env.example index 2a04709..346e01b 100644 --- a/.env.example +++ b/.env.example @@ -46,4 +46,21 @@ AUTO_TIME_TYPE=Default AUTO_TIME_TITLE=Consuntivazione Automatica fine giornata AUTO_TIME_SUBJECT=Consuntivazione automatica ore mancanti AUTO_TIME_BODY=Consuntivazione eseguita automaticamente per il completamento delle ore lavorative giornaliere. -AUTO_TIME_CUSTOMER_USER=client_generic \ No newline at end of file +AUTO_TIME_CUSTOMER_USER=client_generic + +# --- Microsoft Graph API per invio email (metodo primario - Exchange con 2FA) --- +AZURE_TENANT_ID=your_tenant_id_here +AZURE_CLIENT_ID=your_client_id_here +AZURE_CLIENT_SECRET=your_client_secret_here +AZURE_MAIL_SENDER=helpdesk@example.com + +# --- SMTP Classico (fallback se Graph API non disponibile - lasciare vuoto per disabilitare) --- +SMTP_HOST= +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=helpdesk@example.com + +# --- BCC automatico OTRS per tracciamento ticket --- +OTRS_MAIL_BCC=helpdesk@example.com \ No newline at end of file diff --git a/activityDb.js b/activityDb.js index a5c7231..545e6c5 100644 --- a/activityDb.js +++ b/activityDb.js @@ -35,6 +35,18 @@ db.exec(` ) `); +db.exec(` + CREATE TABLE IF NOT EXISTS email_signatures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id INTEGER NOT NULL, + name TEXT NOT NULL, + body_html TEXT NOT NULL DEFAULT '', + is_default INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ) +`); + try { db.exec(`ALTER TABLE agent_settings ADD COLUMN tickets_per_page INTEGER NOT NULL DEFAULT 50`); } catch (e) { diff --git a/package-lock.json b/package-lock.json index 042401a..d9b35f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "dotenv": "^16.4.5", "express": "^4.21.0", "mysql2": "^3.22.5", + "nodemailer": "^9.0.3", "pg": "^8.13.0" } }, @@ -898,6 +899,15 @@ "node": ">=10" } }, + "node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/package.json b/package.json index f479484..6470b64 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dotenv": "^16.4.5", "express": "^4.21.0", "mysql2": "^3.22.5", + "nodemailer": "^9.0.3", "pg": "^8.13.0" }, "keywords": [ diff --git a/public/index.html b/public/index.html index da190da..eaff90b 100644 --- a/public/index.html +++ b/public/index.html @@ -89,6 +89,15 @@ Storico Attività Turbo +
  • + + + + + + Firme Email + +
  • + +
    @@ -409,7 +419,7 @@ const TicketDetailView = { this.noteQuill = null; } - this.bindEvents(); + this.bindEvents(ticket, articles, container); } catch (err) { container.innerHTML = ` @@ -423,7 +433,7 @@ const TicketDetailView = { } }, - bindEvents() { + bindEvents(ticket, articles, container) { // Quick-edit change detection const fields = document.querySelectorAll('.quick-edit-select, #qe-customer-user-id, #qe-customer-id'); const saveBtn = document.getElementById('qe-save'); @@ -670,6 +680,63 @@ const TicketDetailView = { }); } + // Email Compose Button + const btnEmailCompose = document.getElementById('btn-open-email-compose'); + if (btnEmailCompose) { + btnEmailCompose.addEventListener('click', () => { + if (window.EmailCompose) { + EmailCompose.open({ + ticketId: parseInt(btnEmailCompose.dataset.ticketId, 10), + ticketTn: btnEmailCompose.dataset.ticketTn, + ticketTitle: btnEmailCompose.dataset.ticketTitle, + customerEmail: btnEmailCompose.dataset.customerEmail, + }); + } else { + Toast.error('Modulo email non disponibile'); + } + }); + } + + // Article Email Reply Buttons + document.querySelectorAll('.btn-email-article').forEach(btn => { + btn.addEventListener('click', () => { + const articleId = parseInt(btn.dataset.articleId, 10); + const article = articles.find(art => art.article_id == articleId); + if (!article) return; + + if (window.EmailCompose) { + const hasHtml = (article.a_content_type || '').toLowerCase().includes('html') || article.a_body.includes(''); + const quotedBody = hasHtml ? article.a_body : App.escapeHtml(article.a_body || '').replace(/\n/g, '
    '); + + const initialBodyHtml = ` +


    +

    Il ${App.formatDateTime(article.create_time)}, ${App.escapeHtml(article.a_from || 'Sistema')} ha scritto:

    +
    + ${quotedBody} +
    +


    + `; + + // Extract sender email if possible for CC/To + let customerEmail = ticket.customer_email || ''; + const matchEmail = (article.a_from || '').match(/<([^>]+)>/) || (article.a_from || '').match(/([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+)/); + if (matchEmail) { + customerEmail = matchEmail[1]; + } + + EmailCompose.open({ + ticketId: ticket.id, + ticketTn: ticket.tn, + ticketTitle: ticket.title, + customerEmail: customerEmail, + initialBodyHtml: initialBodyHtml, + }); + } else { + Toast.error('Modulo email non disponibile'); + } + }); + }); + // Retrodate Ticket Event Listeners const btnEditTicketDate = document.getElementById('btn-edit-ticket-date'); const ticketDateEditor = document.getElementById('ticket-date-editor'); diff --git a/routes/email.js b/routes/email.js new file mode 100644 index 0000000..865496b --- /dev/null +++ b/routes/email.js @@ -0,0 +1,216 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const pool = require('../db'); +const { db } = require('../activityDb'); +const { sendMail } = require('../utils/mailer'); + +// ─── SIGNATURES ──────────────────────────────────────────────────────────────── + +// GET /api/email/signatures — list signatures for a given agent +router.get('/signatures', (req, res) => { + try { + const { agent_id } = req.query; + if (!agent_id) return res.status(400).json({ error: 'agent_id è obbligatorio' }); + + const rows = db.prepare(` + SELECT id, name, body_html, is_default, created_at, updated_at + FROM email_signatures + WHERE agent_id = ? + ORDER BY is_default DESC, name ASC + `).all(parseInt(agent_id, 10)); + + res.json(rows); + } catch (err) { + console.error('[Email] Error listing signatures:', err); + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/email/signatures — create a new signature +router.post('/signatures', (req, res) => { + try { + const { agent_id, name, body_html, is_default = 0 } = req.body; + if (!agent_id || !name) return res.status(400).json({ error: 'agent_id e name sono obbligatori' }); + + // If new signature is default, reset others for this agent + if (is_default) { + db.prepare(`UPDATE email_signatures SET is_default = 0 WHERE agent_id = ?`).run(parseInt(agent_id, 10)); + } + + const result = db.prepare(` + INSERT INTO email_signatures (agent_id, name, body_html, is_default) + VALUES (?, ?, ?, ?) + `).run(parseInt(agent_id, 10), name, body_html || '', is_default ? 1 : 0); + + res.json({ id: result.lastInsertRowid, agent_id, name, body_html, is_default }); + } catch (err) { + console.error('[Email] Error creating signature:', err); + res.status(500).json({ error: err.message }); + } +}); + +// PUT /api/email/signatures/:id — update a signature +router.put('/signatures/:id', (req, res) => { + try { + const { id } = req.params; + const { agent_id, name, body_html, is_default } = req.body; + + // If new signature is default, reset others for this agent + if (is_default && agent_id) { + db.prepare(`UPDATE email_signatures SET is_default = 0 WHERE agent_id = ?`).run(parseInt(agent_id, 10)); + } + + db.prepare(` + UPDATE email_signatures + SET name = ?, body_html = ?, is_default = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = ? + `).run(name, body_html || '', is_default ? 1 : 0, parseInt(id, 10)); + + const updated = db.prepare(`SELECT * FROM email_signatures WHERE id = ?`).get(parseInt(id, 10)); + res.json(updated); + } catch (err) { + console.error('[Email] Error updating signature:', err); + res.status(500).json({ error: err.message }); + } +}); + +// PATCH /api/email/signatures/:id/default — set a signature as default +router.patch('/signatures/:id/default', (req, res) => { + try { + const { id } = req.params; + const { agent_id } = req.body; + + if (agent_id) { + db.prepare(`UPDATE email_signatures SET is_default = 0 WHERE agent_id = ?`).run(parseInt(agent_id, 10)); + } + + db.prepare(` + UPDATE email_signatures SET is_default = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = ? + `).run(parseInt(id, 10)); + + res.json({ success: true }); + } catch (err) { + console.error('[Email] Error setting default signature:', err); + res.status(500).json({ error: err.message }); + } +}); + +// DELETE /api/email/signatures/:id — delete a signature +router.delete('/signatures/:id', (req, res) => { + try { + const { id } = req.params; + db.prepare(`DELETE FROM email_signatures WHERE id = ?`).run(parseInt(id, 10)); + res.json({ success: true }); + } catch (err) { + console.error('[Email] Error deleting signature:', err); + res.status(500).json({ error: err.message }); + } +}); + +// ─── SEND EMAIL ─────────────────────────────────────────────────────────────── + +// POST /api/email/send — send an email for a ticket +router.post('/send', async (req, res) => { + const { + ticketId, + to, + cc = [], + subject: customSubject, + bodyHtml, + attachments = [], + inlineImages = [], + agentId, + agentName = 'Agente', + keepHelpdeskCopy = true, + } = req.body; + + if (!ticketId) return res.status(400).json({ error: 'ticketId è obbligatorio' }); + if (!to || !to.length) return res.status(400).json({ error: 'Il campo "to" è obbligatorio' }); + if (!bodyHtml) return res.status(400).json({ error: 'Il corpo della email è obbligatorio' }); + + try { + // 1. Fetch ticket number and title for subject + const ticketResult = await pool.query( + `SELECT tn, title FROM ticket WHERE id = $1`, + [ticketId] + ); + if (!ticketResult.rows.length) return res.status(404).json({ error: 'Ticket non trovato' }); + + const { tn, title } = ticketResult.rows[0]; + const subject = customSubject || `Re: [Ticket#${tn}] ${title}`; + + // 2. Build BCC list (include OTRS system mailbox if keepHelpdeskCopy is true) + const bcc = []; + if (keepHelpdeskCopy) { + const otrsBcc = process.env.OTRS_MAIL_BCC; + if (otrsBcc) bcc.push(otrsBcc); + } + + // 3. Send via configured mailer (Graph API or SMTP) + await sendMail({ to, cc, bcc, subject, bodyHtml, attachments, inlineImages }); + + // 4. Log internal note in OTRS ticket via DB (email sent record) + try { + const now = Math.floor(Date.now() / 1000); + const agentLoginResult = agentId + ? await pool.query(`SELECT login, first_name, last_name FROM users WHERE id = $1`, [agentId]) + : { rows: [] }; + + const agentUser = agentLoginResult.rows[0]; + let agentEmail = 'agent@localhost'; + if (agentId) { + const prefRes = await pool.query( + `SELECT preferences_value FROM user_preferences WHERE user_id = $1 AND preferences_key = 'UserEmail'`, + [agentId] + ); + if (prefRes.rows.length > 0 && prefRes.rows[0].preferences_value) { + agentEmail = prefRes.rows[0].preferences_value; + } + } + + const aFrom = agentUser + ? `"${agentUser.first_name} ${agentUser.last_name}" <${agentEmail}>` + : agentName; + + const toList = to.join(', '); + const noteBody = `Email inviata a: ${toList}${cc.length ? `\nCC: ${cc.join(', ')}` : ''}`; + + // Insert article via DB (internal note to log email dispatch) + const artInsert = await pool.query(` + INSERT INTO article ( + ticket_id, article_sender_type_id, communication_channel_id, + is_visible_for_customer, a_from, a_to, a_subject, a_body, + content_path, incoming_time, create_time, create_by, change_time, change_by + ) VALUES ( + $1, 1, 2, 0, $2, $3, $4, $5, + '/', $6, NOW(), $7, NOW(), $7 + ) RETURNING id`, + [ticketId, aFrom, toList, `[Email inviata] ${subject}`, noteBody, now, agentId || 1] + ); + + const articleId = artInsert.rows[0]?.id; + + if (articleId) { + await pool.query(` + INSERT INTO article_data_mime (article_id, a_from, a_to, a_cc, a_subject, a_body, a_content_type, incoming_time, create_time, create_by, change_time, change_by) + VALUES ($1, $2, $3, $4, $5, $6, 'text/plain; charset=utf-8', $7, NOW(), $8, NOW(), $8)`, + [articleId, aFrom, toList, cc.join(', '), subject, noteBody, now, agentId || 1] + ); + } + } catch (noteErr) { + console.warn('[Email] Nota interna OTRS non inserita (non bloccante):', noteErr.message); + } + + console.log(`[Email] ✅ Email inviata per ticket #${tn} a: ${to.join(', ')}`); + res.json({ success: true, subject, to }); + + } catch (err) { + console.error('[Email] ❌ Errore invio email:', err.message); + res.status(500).json({ error: err.message }); + } +}); + +module.exports = router; diff --git a/routes/tickets.js b/routes/tickets.js index 4e7d213..70da6ca 100644 --- a/routes/tickets.js +++ b/routes/tickets.js @@ -23,13 +23,18 @@ async function resolveAgentName(agentId) { async function resolveAgentFromHeader(agentId, client = pool) { try { const r = await client.query( - `SELECT first_name, last_name, email, login FROM users WHERE id = $1`, + `SELECT first_name, last_name, login FROM users WHERE id = $1`, [agentId] ); if (r.rows.length > 0) { const u = r.rows[0]; const fullName = [u.first_name, u.last_name].filter(Boolean).join(' ') || u.login || 'Agent'; - const email = u.email || 'agent@localhost'; + + const prefRes = await client.query( + `SELECT preferences_value FROM user_preferences WHERE user_id = $1 AND preferences_key = 'UserEmail'`, + [agentId] + ); + const email = (prefRes.rows.length > 0 && prefRes.rows[0].preferences_value) || 'agent@localhost'; return `"${fullName}" <${email}>`; } } catch (_) { /* ignore */ } diff --git a/server.js b/server.js index e6a5718..5ed9b07 100644 --- a/server.js +++ b/server.js @@ -7,6 +7,7 @@ const ticketsRouter = require('./routes/tickets'); const lookupsRouter = require('./routes/lookups'); const dashboardRouter = require('./routes/dashboard'); const activityRouter = require('./routes/activity'); +const emailRouter = require('./routes/email'); const app = express(); const PORT = process.env.PORT || 3000; @@ -23,6 +24,7 @@ app.use('/api/tickets', ticketsRouter); app.use('/api', lookupsRouter); app.use('/api/dashboard', dashboardRouter); app.use('/api/attivita', activityRouter); +app.use('/api/email', emailRouter); // SPA fallback — serve index.html for all non-API routes app.get('*', (req, res) => { diff --git a/utils/graphMailer.js b/utils/graphMailer.js new file mode 100644 index 0000000..5f201c3 --- /dev/null +++ b/utils/graphMailer.js @@ -0,0 +1,131 @@ +/** + * 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 = [] }) { + 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 payload = { + message: { + subject, + body: { + contentType: 'HTML', + content: bodyHtml, + }, + toRecipients, + ccRecipients, + bccRecipients, + attachments: allAttachments, + }, + saveToSentItems: false, + }; + + 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) { + return; // Successo (Graph API risponde con 202 No Content) + } + + const errText = await res.text(); + throw new Error(`[Graph Mail] Errore invio email: ${res.status} ${errText}`); +} + +module.exports = { sendMail }; diff --git a/utils/mailer.js b/utils/mailer.js new file mode 100644 index 0000000..9e61f79 --- /dev/null +++ b/utils/mailer.js @@ -0,0 +1,35 @@ +/** + * utils/mailer.js + * Factory: seleziona il metodo di invio email corretto in base alla configurazione .env. + * Priorità: Graph API (se AZURE_TENANT_ID configurato) → SMTP (se SMTP_HOST configurato) + */ +'use strict'; + +function getMailer() { + if (process.env.AZURE_TENANT_ID && process.env.AZURE_CLIENT_ID && process.env.AZURE_CLIENT_SECRET) { + return require('./graphMailer'); + } + if (process.env.SMTP_HOST) { + return require('./smtpMailer'); + } + throw new Error('[Mailer] Nessun metodo di invio email configurato. Impostare AZURE_TENANT_ID oppure SMTP_HOST nel .env.'); +} + +/** + * Invia una email usando il metodo configurato (Graph API o SMTP). + * + * @param {Object} options + * @param {string[]} options.to + * @param {string[]} [options.cc] + * @param {string[]} [options.bcc] + * @param {string} options.subject + * @param {string} options.bodyHtml + * @param {Array} [options.attachments] + * @param {Array} [options.inlineImages] + */ +async function sendMail(options) { + const mailer = getMailer(); + return mailer.sendMail(options); +} + +module.exports = { sendMail }; diff --git a/utils/smtpMailer.js b/utils/smtpMailer.js new file mode 100644 index 0000000..74ba280 --- /dev/null +++ b/utils/smtpMailer.js @@ -0,0 +1,60 @@ +/** + * utils/smtpMailer.js + * Fallback SMTP per l'invio email via nodemailer. + * Usato se AZURE_TENANT_ID non è configurato ma SMTP_HOST lo è. + */ +'use strict'; + +const nodemailer = require('nodemailer'); + +let _transporter = null; + +function getTransporter() { + if (_transporter) return _transporter; + + _transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: parseInt(process.env.SMTP_PORT || '587', 10), + secure: process.env.SMTP_SECURE === 'true', + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASSWORD, + }, + }); + + return _transporter; +} + +/** + * Invia una email tramite SMTP (nodemailer). + * Stessa interfaccia di graphMailer.sendMail. + */ +async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments = [], inlineImages = [] }) { + const transporter = getTransporter(); + + const mailOptions = { + from: process.env.SMTP_FROM || process.env.SMTP_USER, + to: to.join(', '), + cc: cc.length ? cc.join(', ') : undefined, + bcc: bcc.length ? bcc.join(', ') : undefined, + subject, + html: bodyHtml, + attachments: [ + ...attachments.map(a => ({ + filename: a.filename, + content: Buffer.from(a.content, 'base64'), + contentType: a.contentType || 'application/octet-stream', + })), + ...inlineImages.map(img => ({ + filename: img.cid, + cid: img.cid, + content: Buffer.from(img.content, 'base64'), + contentType: img.contentType || 'image/png', + })), + ], + }; + + await transporter.sendMail(mailOptions); +} + +module.exports = { sendMail };