diff --git a/activityDb.js b/activityDb.js index 75d4f4e..368d22f 100644 --- a/activityDb.js +++ b/activityDb.js @@ -77,6 +77,17 @@ db.exec(` ) `); +db.exec(` + CREATE TABLE IF NOT EXISTS email_address_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id INTEGER NOT NULL, + name TEXT NOT NULL, + emails TEXT NOT NULL, + 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/public/index.html b/public/index.html index a9c9ab7..4a53914 100644 --- a/public/index.html +++ b/public/index.html @@ -93,12 +93,12 @@
  • - + - Firme Email + Gestione Mail
  • @@ -197,7 +197,7 @@ - + diff --git a/public/js/app.js b/public/js/app.js index 5d4f67f..b10b2f2 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -152,10 +152,10 @@ const App = { titleEl.textContent = 'Storico Attività'; ActivityLogView.render(); - } else if (hash === '#/signatures') { - document.getElementById('nav-signatures')?.classList.add('active'); - titleEl.textContent = 'Firme Email'; - SignaturesView.render(); + } else if (hash === '#/mail-management') { + document.getElementById('nav-mail-management')?.classList.add('active'); + titleEl.textContent = 'Gestione Mail'; + MailManagementView.render(); } else if (hash.match(/^#\/tickets\/(\d+)$/)) { const id = hash.match(/^#\/tickets\/(\d+)$/)[1]; diff --git a/public/js/views/emailCompose.js b/public/js/views/emailCompose.js index 5f39df0..4e2c373 100644 --- a/public/js/views/emailCompose.js +++ b/public/js/views/emailCompose.js @@ -145,7 +145,7 @@ const EmailCompose = (() => { } // ── Tag Input Helper ────────────────────────────────────────────────────────── - function makeTagInput(containerId, initialEmails = []) { + function makeTagInput(containerId, initialEmails = [], onFocus = null) { const container = document.getElementById(containerId); const tags = [...initialEmails]; @@ -168,6 +168,11 @@ const EmailCompose = (() => { input.type = 'text'; input.placeholder = tags.length ? '' : 'email@esempio.com, premi Invio'; input.value = currentVal; + + if (onFocus) { + input.addEventListener('focus', onFocus); + } + input.addEventListener('keydown', (e) => { if ((e.key === 'Enter' || e.key === ',') && input.value.trim()) { e.preventDefault(); @@ -221,6 +226,10 @@ const EmailCompose = (() => {
    +
    + +
    +
    @@ -233,10 +242,16 @@ const EmailCompose = (() => {
    - + + +
    +
    +
    @@ -310,6 +325,23 @@ const EmailCompose = (() => { return ''; } + // ── Load Address Groups ──────────────────────────────────────────────────────── + async function loadAddressGroups(agentId, selectEl) { + try { + const groups = await App.api(`/api/email/address-groups?agent_id=${agentId}`); + selectEl.innerHTML = ''; + groups.forEach(g => { + const opt = document.createElement('option'); + opt.value = g.id; + opt.textContent = g.name; + opt.dataset.emails = g.emails; + selectEl.appendChild(opt); + }); + } catch (e) { + console.warn('[EmailCompose] Address groups load error:', e); + } + } + // ── Open ───────────────────────────────────────────────────────────────────── async function open(options = {}) { injectStyles(); @@ -323,11 +355,14 @@ const EmailCompose = (() => { const overlay = buildModal(); document.body.appendChild(overlay); - // Init tag inputs + // Init tag inputs with focus tracking const initialTo = options.initialTo || (options.customerEmail ? [options.customerEmail] : []); const initialCc = options.initialCc || []; - const toTagsCtrl = makeTagInput('ec-to-container', initialTo); - const ccTagsCtrl = makeTagInput('ec-cc-container', initialCc); + let lastFocusedCtrl = null; + const toTagsCtrl = makeTagInput('ec-to-container', initialTo, () => { lastFocusedCtrl = toTagsCtrl; }); + const ccTagsCtrl = makeTagInput('ec-cc-container', initialCc, () => { lastFocusedCtrl = ccTagsCtrl; }); + const bccTagsCtrl = makeTagInput('ec-bcc-container', [], () => { lastFocusedCtrl = bccTagsCtrl; }); + lastFocusedCtrl = toTagsCtrl; // Subject const subjectEl = document.getElementById('ec-subject'); @@ -335,10 +370,27 @@ const EmailCompose = (() => { const title = options.ticketTitle || ''; subjectEl.value = tn ? `Re: [Ticket#${tn}] ${title}` : title; - // Signature select + // Signature and groups select const sigSelect = document.getElementById('ec-signature-select'); + const groupsSelect = document.getElementById('ec-groups-select'); const agentId = App.currentAgentId || 0; const defaultSigHtml = await loadSignatures(agentId, sigSelect); + await loadAddressGroups(agentId, groupsSelect); + + groupsSelect.addEventListener('change', () => { + const selectedOpt = groupsSelect.options[groupsSelect.selectedIndex]; + if (!selectedOpt || !selectedOpt.value) return; + + const emailsStr = selectedOpt.dataset.emails || ''; + const emails = emailsStr.split(',').map(e => e.trim()).filter(Boolean); + + if (lastFocusedCtrl) { + emails.forEach(email => { + lastFocusedCtrl.addTag(email); + }); + } + groupsSelect.value = ''; + }); // Quill editor quillEditor = new Quill('#ec-quill-editor', { @@ -405,7 +457,7 @@ const EmailCompose = (() => { overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); // Send - document.getElementById('ec-send').addEventListener('click', () => sendEmail(toTagsCtrl, ccTagsCtrl)); + document.getElementById('ec-send').addEventListener('click', () => sendEmail(toTagsCtrl, ccTagsCtrl, bccTagsCtrl)); } function processFiles(files) { @@ -421,9 +473,10 @@ const EmailCompose = (() => { } // ── Send ───────────────────────────────────────────────────────────────────── - async function sendEmail(toCtrl, ccCtrl) { + async function sendEmail(toCtrl, ccCtrl, bccCtrl) { const to = toCtrl.getTags(); const cc = ccCtrl.getTags(); + const bcc = bccCtrl.getTags(); const subject = document.getElementById('ec-subject').value.trim(); const bodyHtml = quillEditor ? quillEditor.root.innerHTML : ''; @@ -438,10 +491,12 @@ const EmailCompose = (() => { const agentId = App.currentAgentId || 0; const payload = { ticketId: currentOptions.ticketId, - to, cc, subject, bodyHtml, + to, cc, bcc, subject, bodyHtml, attachments: attachmentsList, agentId, keepHelpdeskCopy: document.getElementById('ec-helpdesk-cc-select').value === '1', + inReplyTo: currentOptions.inReplyTo, + references: currentOptions.references, }; const res = await App.api('/api/email/send', { diff --git a/public/js/views/mailManagement.js b/public/js/views/mailManagement.js new file mode 100644 index 0000000..68123eb --- /dev/null +++ b/public/js/views/mailManagement.js @@ -0,0 +1,483 @@ +/** + * mailManagement.js + * Pagina di gestione mail: include la gestione dei Gruppi di Indirizzi + * e la gestione delle firme email per l'agente attivo. + */ + +const MailManagementView = { + agentId: null, + signatures: [], + groups: [], + editingSigId: null, + editingGroupId: null, + signatureQuill: null, + + async render() { + this.agentId = App.currentAgentId || 0; + const container = document.getElementById('view-container'); + + // Inject styles for tooltip and layout + this.injectStyles(); + + container.innerHTML = ` +
    + + +
    +
    +
    +

    👥 Gruppi di Indirizzi

    +

    Crea gruppi di contatti da inserire rapidamente nei campi A, CC o BCC.

    +
    + +
    + +
    +
    + + +
    +
    +
    +

    ✉️ Le Mie Firme Email

    +

    Gestisci le firme da allegare automaticamente alle tue email.

    +
    + +
    + +
    +
    + + + + + + + +
    + `; + + // Signatures events + document.getElementById('btn-new-signature').addEventListener('click', () => this.openSigEditor(null)); + + // Address Groups events + document.getElementById('btn-new-group').addEventListener('click', () => this.openGroupEditor(null)); + + // Load data + await Promise.all([ + this.loadSignatures(), + this.loadGroups() + ]); + }, + + injectStyles() { + if (document.getElementById('mail-management-styles')) return; + const style = document.createElement('style'); + style.id = 'mail-management-styles'; + style.textContent = ` + .g-card { + background: var(--bg-secondary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: var(--space-md); + position: relative; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 12px; + transition: transform 0.2s, box-shadow 0.2s; + } + .g-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-sm); + border-color: var(--accent-primary); + } + .g-tooltip { + visibility: hidden; + opacity: 0; + position: absolute; + bottom: 105%; + left: 50%; + transform: translateX(-50%); + background: var(--bg-tertiary, #2c2c3e); + border: 1px solid var(--border-light, #444); + color: var(--text-primary, #fff); + padding: 8px 12px; + border-radius: 6px; + font-size: 0.78rem; + z-index: 100; + white-space: pre-line; + box-shadow: var(--shadow-lg); + pointer-events: none; + transition: opacity 0.2s, visibility 0.2s; + max-width: 280px; + width: max-content; + } + .g-card:hover .g-tooltip { + visibility: visible; + opacity: 1; + } + `; + document.head.appendChild(style); + }, + + // ─── SIGNATURES LOGIC ──────────────────────────────────────────────────────── + async loadSignatures() { + try { + this.signatures = await App.api(`/api/email/signatures?agent_id=${this.agentId}`); + this.renderSignatures(); + } catch (err) { + Toast.error('Errore caricamento firme: ' + err.message); + } + }, + + renderSignatures() { + const list = document.getElementById('signatures-list'); + if (!list) return; + + if (!this.signatures.length) { + list.innerHTML = ` +
    +
    ✉️
    +
    Nessuna firma configurata
    +
    + `; + return; + } + + list.innerHTML = this.signatures.map(sig => ` +
    +
    +
    +
    + ${App.escapeHtml(sig.name)} + ${sig.is_default ? 'Predefinita' : ''} +
    +
    +
    + ${!sig.is_default ? `` : ''} + + +
    +
    + ${sig.body_html ? ` +
    +
    ${sig.body_html}
    +
    +
    + ` : ''} +
    + `).join(''); + + // Bind buttons + list.querySelectorAll('.sig-btn-edit').forEach(btn => { + btn.addEventListener('click', () => this.openSigEditor(parseInt(btn.dataset.id, 10))); + }); + list.querySelectorAll('.sig-btn-delete').forEach(btn => { + btn.addEventListener('click', () => this.deleteSig(parseInt(btn.dataset.id, 10))); + }); + list.querySelectorAll('.sig-btn-default').forEach(btn => { + btn.addEventListener('click', () => this.setDefaultSig(parseInt(btn.dataset.id, 10))); + }); + }, + + openSigEditor(id) { + this.editingSigId = id; + const sig = id ? this.signatures.find(s => s.id === id) : null; + + const modal = document.getElementById('signature-editor-modal'); + modal.style.display = 'flex'; + + document.getElementById('sig-modal-title').textContent = id ? 'Modifica Firma' : 'Nuova Firma'; + document.getElementById('sig-name').value = sig ? sig.name : ''; + document.getElementById('sig-is-default').checked = sig ? !!sig.is_default : false; + + // Init or reset Quill + if (this.signatureQuill) { + this.signatureQuill.root.innerHTML = sig ? (sig.body_html || '') : ''; + } else { + this.signatureQuill = new Quill('#sig-quill-editor', { + theme: 'snow', + placeholder: 'Inserisci la tua firma...', + modules: { + toolbar: [ + ['bold', 'italic', 'underline'], + [{ 'color': [] }], + ['link', 'image'], + ['clean'] + ] + } + }); + if (sig && sig.body_html) { + this.signatureQuill.clipboard.dangerouslyPasteHTML(sig.body_html); + } + } + + document.getElementById('sig-modal-close').onclick = () => this.closeSigEditor(); + document.getElementById('sig-cancel').onclick = () => this.closeSigEditor(); + document.getElementById('sig-save').onclick = () => this.saveSig(); + + modal.onclick = (e) => { if (e.target === modal) this.closeSigEditor(); }; + }, + + closeSigEditor() { + const modal = document.getElementById('signature-editor-modal'); + if (modal) modal.style.display = 'none'; + this.editingSigId = null; + }, + + async saveSig() { + const name = document.getElementById('sig-name').value.trim(); + const body_html = this.signatureQuill ? this.signatureQuill.root.innerHTML : ''; + const is_default = document.getElementById('sig-is-default').checked ? 1 : 0; + + if (!name) { Toast.warning('Inserisci un nome per la firma'); return; } + + const saveBtn = document.getElementById('sig-save'); + saveBtn.disabled = true; + saveBtn.textContent = 'Salvataggio...'; + + try { + const payload = { agent_id: this.agentId, name, body_html, is_default }; + + if (this.editingSigId) { + await App.api(`/api/email/signatures/${this.editingSigId}`, { + method: 'PUT', + body: JSON.stringify(payload), + }); + Toast.success('Firma aggiornata'); + } else { + await App.api('/api/email/signatures', { + method: 'POST', + body: JSON.stringify(payload), + }); + Toast.success('Firma creata'); + } + + this.closeSigEditor(); + await this.loadSignatures(); + } catch (err) { + Toast.error('Errore salvataggio: ' + err.message); + } finally { + saveBtn.disabled = false; + saveBtn.textContent = 'Salva Firma'; + } + }, + + async deleteSig(id) { + const ok = await App.confirm('Elimina Firma', 'Sei sicuro di voler eliminare questa firma?'); + if (!ok) return; + try { + await App.api(`/api/email/signatures/${id}`, { method: 'DELETE' }); + Toast.success('Firma eliminata'); + await this.loadSignatures(); + } catch (err) { + Toast.error('Errore eliminazione: ' + err.message); + } + }, + + async setDefaultSig(id) { + try { + await App.api(`/api/email/signatures/${id}/default`, { + method: 'PATCH', + body: JSON.stringify({ agent_id: this.agentId }), + }); + Toast.success('Firma impostata come predefinita'); + await this.loadSignatures(); + } catch (err) { + Toast.error('Errore: ' + err.message); + } + }, + + + // ─── ADDRESS GROUPS LOGIC ────────────────────────────────────────────────── + async loadGroups() { + try { + this.groups = await App.api(`/api/email/address-groups?agent_id=${this.agentId}`); + this.renderGroups(); + } catch (err) { + Toast.error('Errore caricamento gruppi: ' + err.message); + } + }, + + renderGroups() { + const list = document.getElementById('groups-list'); + if (!list) return; + + if (!this.groups.length) { + list.innerHTML = ` +
    +
    👥
    +
    Nessun gruppo configurato
    +
    + `; + return; + } + + list.innerHTML = this.groups.map(g => { + const emailList = g.emails.split(',').map(e => e.trim()).filter(Boolean); + const tooltipText = emailList.length ? emailList.join('\n') : '(nessun indirizzo)'; + const count = emailList.length; + + return ` +
    +
    Contatti (${count}):\n${App.escapeHtml(tooltipText)}
    +
    +
    ${App.escapeHtml(g.name)}
    +
    + ${count} indirizz${count === 1 ? 'o' : 'i'} email +
    +
    +
    + + +
    +
    + `; + }).join(''); + + // Bind group buttons + list.querySelectorAll('.group-btn-edit').forEach(btn => { + btn.addEventListener('click', () => this.openGroupEditor(parseInt(btn.dataset.id, 10))); + }); + list.querySelectorAll('.group-btn-delete').forEach(btn => { + btn.addEventListener('click', () => this.deleteGroup(parseInt(btn.dataset.id, 10))); + }); + }, + + openGroupEditor(id) { + this.editingGroupId = id; + const group = id ? this.groups.find(g => g.id === id) : null; + + const modal = document.getElementById('group-editor-modal'); + modal.style.display = 'flex'; + + document.getElementById('group-modal-title').textContent = id ? 'Modifica Gruppo' : 'Nuovo Gruppo'; + document.getElementById('group-name').value = group ? group.name : ''; + document.getElementById('group-emails').value = group ? group.emails : ''; + + document.getElementById('group-modal-close').onclick = () => this.closeGroupEditor(); + document.getElementById('group-cancel').onclick = () => this.closeGroupEditor(); + document.getElementById('group-save').onclick = () => this.saveGroup(); + + modal.onclick = (e) => { if (e.target === modal) this.closeGroupEditor(); }; + }, + + closeGroupEditor() { + const modal = document.getElementById('group-editor-modal'); + if (modal) modal.style.display = 'none'; + this.editingGroupId = null; + }, + + async saveGroup() { + const name = document.getElementById('group-name').value.trim(); + const emails = document.getElementById('group-emails').value.trim(); + + if (!name) { Toast.warning('Inserisci un nome per il gruppo'); return; } + if (!emails) { Toast.warning('Inserisci almeno un indirizzo email'); return; } + + const parsedEmails = emails.split(',').map(e => e.trim()).filter(Boolean); + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + const invalid = parsedEmails.filter(e => !emailRegex.test(e)); + if (invalid.length > 0) { + Toast.warning('I seguenti indirizzi non sono validi: ' + invalid.join(', ')); + return; + } + + const saveBtn = document.getElementById('group-save'); + saveBtn.disabled = true; + saveBtn.textContent = 'Salvataggio...'; + + try { + const payload = { agent_id: this.agentId, name, emails: parsedEmails.join(', ') }; + + if (this.editingGroupId) { + await App.api(`/api/email/address-groups/${this.editingGroupId}`, { + method: 'PUT', + body: JSON.stringify(payload), + }); + Toast.success('Gruppo aggiornato'); + } else { + await App.api('/api/email/address-groups', { + method: 'POST', + body: JSON.stringify(payload), + }); + Toast.success('Gruppo creato'); + } + + this.closeGroupEditor(); + await this.loadGroups(); + } catch (err) { + Toast.error('Errore salvataggio: ' + err.message); + } finally { + saveBtn.disabled = false; + saveBtn.textContent = 'Salva Gruppo'; + } + }, + + async deleteGroup(id) { + const ok = await App.confirm('Elimina Gruppo', 'Sei sicuro di voler eliminare questo gruppo di indirizzi?'); + if (!ok) return; + try { + await App.api(`/api/email/address-groups/${id}`, { method: 'DELETE' }); + Toast.success('Gruppo eliminato'); + await this.loadGroups(); + } catch (err) { + Toast.error('Errore eliminazione: ' + err.message); + } + } +}; + +window.MailManagementView = MailManagementView; diff --git a/public/js/views/signatures.js b/public/js/views/signatures.js deleted file mode 100644 index a083492..0000000 --- a/public/js/views/signatures.js +++ /dev/null @@ -1,239 +0,0 @@ -/** - * signatures.js - * Pagina di gestione delle firme email per agente. - * Usa Quill.js come editor WYSIWYG. - */ - -const SignaturesView = { - agentId: null, - signatures: [], - editingId: null, - signatureQuill: null, - - async render() { - this.agentId = App.currentAgentId || 0; - const container = document.getElementById('view-container'); - container.innerHTML = ` -
    -
    -
    -

    ✉️ Le Mie Firme Email

    -

    Gestisci le firme da allegare automaticamente alle email inviate dal ticket.

    -
    - -
    - - -
    - - - -
    - `; - - document.getElementById('btn-new-signature').addEventListener('click', () => this.openEditor(null)); - - await this.loadSignatures(); - }, - - async loadSignatures() { - try { - this.signatures = await App.api(`/api/email/signatures?agent_id=${this.agentId}`); - this.renderList(); - } catch (err) { - Toast.error('Errore caricamento firme: ' + err.message); - } - }, - - renderList() { - const list = document.getElementById('signatures-list'); - if (!list) return; - - if (!this.signatures.length) { - list.innerHTML = ` -
    -
    ✉️
    -
    Nessuna firma configurata
    -
    Crea la tua prima firma per velocizzare l'invio delle email.
    -
    - `; - return; - } - - list.innerHTML = this.signatures.map(sig => ` -
    -
    -
    -
    - ${App.escapeHtml(sig.name)} - ${sig.is_default ? 'Predefinita' : ''} -
    -
    - Creata: ${new Date(sig.created_at).toLocaleDateString('it-IT')} -
    -
    -
    - ${!sig.is_default ? `` : ''} - - -
    -
    - ${sig.body_html ? ` -
    -
    ${sig.body_html}
    -
    -
    - ` : ''} -
    - `).join(''); - - // Bind buttons - list.querySelectorAll('.sig-btn-edit').forEach(btn => { - btn.addEventListener('click', () => this.openEditor(parseInt(btn.dataset.id, 10))); - }); - list.querySelectorAll('.sig-btn-delete').forEach(btn => { - btn.addEventListener('click', () => this.deleteSig(parseInt(btn.dataset.id, 10))); - }); - list.querySelectorAll('.sig-btn-default').forEach(btn => { - btn.addEventListener('click', () => this.setDefault(parseInt(btn.dataset.id, 10))); - }); - }, - - openEditor(id) { - this.editingId = id; - const sig = id ? this.signatures.find(s => s.id === id) : null; - - const modal = document.getElementById('signature-editor-modal'); - modal.style.display = 'flex'; - - document.getElementById('sig-modal-title').textContent = id ? 'Modifica Firma' : 'Nuova Firma'; - document.getElementById('sig-name').value = sig ? sig.name : ''; - document.getElementById('sig-is-default').checked = sig ? !!sig.is_default : false; - - // Init or reset Quill - if (this.signatureQuill) { - this.signatureQuill.root.innerHTML = sig ? (sig.body_html || '') : ''; - } else { - this.signatureQuill = new Quill('#sig-quill-editor', { - theme: 'snow', - placeholder: 'Inserisci la tua firma...', - modules: { - toolbar: [ - ['bold', 'italic', 'underline'], - [{ 'color': [] }], - ['link', 'image'], - ['clean'] - ] - } - }); - if (sig && sig.body_html) { - this.signatureQuill.clipboard.dangerouslyPasteHTML(sig.body_html); - } - } - - document.getElementById('sig-modal-close').onclick = () => this.closeEditor(); - document.getElementById('sig-cancel').onclick = () => this.closeEditor(); - document.getElementById('sig-save').onclick = () => this.saveSig(); - - modal.onclick = (e) => { if (e.target === modal) this.closeEditor(); }; - }, - - closeEditor() { - const modal = document.getElementById('signature-editor-modal'); - if (modal) modal.style.display = 'none'; - this.editingId = null; - }, - - async saveSig() { - const name = document.getElementById('sig-name').value.trim(); - const body_html = this.signatureQuill ? this.signatureQuill.root.innerHTML : ''; - const is_default = document.getElementById('sig-is-default').checked ? 1 : 0; - - if (!name) { Toast.warning('Inserisci un nome per la firma'); return; } - - const saveBtn = document.getElementById('sig-save'); - saveBtn.disabled = true; - saveBtn.textContent = 'Salvataggio...'; - - try { - const payload = { agent_id: this.agentId, name, body_html, is_default }; - - if (this.editingId) { - await App.api(`/api/email/signatures/${this.editingId}`, { - method: 'PUT', - body: JSON.stringify(payload), - }); - Toast.success('Firma aggiornata'); - } else { - await App.api('/api/email/signatures', { - method: 'POST', - body: JSON.stringify(payload), - }); - Toast.success('Firma creata'); - } - - this.closeEditor(); - await this.loadSignatures(); - } catch (err) { - Toast.error('Errore salvataggio: ' + err.message); - } finally { - saveBtn.disabled = false; - saveBtn.textContent = 'Salva Firma'; - } - }, - - async deleteSig(id) { - const ok = await App.confirm('Elimina Firma', 'Sei sicuro di voler eliminare questa firma?'); - if (!ok) return; - try { - await App.api(`/api/email/signatures/${id}`, { method: 'DELETE' }); - Toast.success('Firma eliminata'); - await this.loadSignatures(); - } catch (err) { - Toast.error('Errore eliminazione: ' + err.message); - } - }, - - async setDefault(id) { - try { - await App.api(`/api/email/signatures/${id}/default`, { - method: 'PATCH', - body: JSON.stringify({ agent_id: this.agentId }), - }); - Toast.success('Firma impostata come predefinita'); - await this.loadSignatures(); - } catch (err) { - Toast.error('Errore: ' + err.message); - } - } -}; -window.SignaturesView = SignaturesView; diff --git a/public/js/views/ticketDetail.js b/public/js/views/ticketDetail.js index 6dcc623..d93823b 100644 --- a/public/js/views/ticketDetail.js +++ b/public/js/views/ticketDetail.js @@ -790,6 +790,14 @@ const TicketDetailView = { } }); + let inReplyTo = article.a_message_id || ''; + let references = ''; + if (article.a_references) { + references = article.a_references + (article.a_message_id ? ' ' + article.a_message_id : ''); + } else if (article.a_message_id) { + references = article.a_message_id; + } + EmailCompose.open({ ticketId: ticket.id, ticketTn: ticket.tn, @@ -797,6 +805,8 @@ const TicketDetailView = { initialTo: Array.from(initialToSet), initialCc: Array.from(initialCcSet), initialBodyHtml: initialBodyHtml, + inReplyTo: inReplyTo, + references: references, }); } else { Toast.error('Modulo email non disponibile'); diff --git a/routes/email.js b/routes/email.js index 865496b..b4cffd3 100644 --- a/routes/email.js +++ b/routes/email.js @@ -110,98 +110,287 @@ router.delete('/signatures/:id', (req, res) => { } }); +// ─── ADDRESS GROUPS ──────────────────────────────────────────────────────────── + +// GET /api/email/address-groups — list groups for a given agent +router.get('/address-groups', (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, emails, created_at, updated_at + FROM email_address_groups + WHERE agent_id = ? + ORDER BY name ASC + `).all(parseInt(agent_id, 10)); + + res.json(rows); + } catch (err) { + console.error('[Email] Error fetching address groups:', err); + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/email/address-groups — create a new group +router.post('/address-groups', (req, res) => { + try { + const { agent_id, name, emails } = req.body; + if (!agent_id || !name || !emails) { + return res.status(400).json({ error: 'Campi agent_id, name e emails sono obbligatori' }); + } + + const info = db.prepare(` + INSERT INTO email_address_groups (agent_id, name, emails) + VALUES (?, ?, ?) + `).run(parseInt(agent_id, 10), name.trim(), emails.trim()); + + res.json({ id: info.lastInsertRowid, success: true }); + } catch (err) { + console.error('[Email] Error creating address group:', err); + res.status(500).json({ error: err.message }); + } +}); + +// PUT /api/email/address-groups/:id — update a group +router.put('/address-groups/:id', (req, res) => { + try { + const { id } = req.params; + const { name, emails } = req.body; + if (!name || !emails) { + return res.status(400).json({ error: 'Campi name e emails sono obbligatori' }); + } + + db.prepare(` + UPDATE email_address_groups + SET name = ?, emails = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = ? + `).run(name.trim(), emails.trim(), parseInt(id, 10)); + + res.json({ success: true }); + } catch (err) { + console.error('[Email] Error updating address group:', err); + res.status(500).json({ error: err.message }); + } +}); + +// DELETE /api/email/address-groups/:id — delete a group +router.delete('/address-groups/:id', (req, res) => { + try { + const { id } = req.params; + db.prepare(`DELETE FROM email_address_groups WHERE id = ?`).run(parseInt(id, 10)); + res.json({ success: true }); + } catch (err) { + console.error('[Email] Error deleting address group:', 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; + const { + ticketId, + to, + cc = [], + bcc = [], + subject: customSubject, + bodyHtml, + attachments = [], + inlineImages = [], + agentId, + agentName = 'Agente', + keepHelpdeskCopy = true, + inReplyTo, + references, + } = 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' }); + 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' }); + 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}`; + 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); - } + // 2. Build BCC list (include OTRS system mailbox if keepHelpdeskCopy is true) + const bccList = [...bcc]; + if (keepHelpdeskCopy) { + const otrsBcc = process.env.OTRS_MAIL_BCC; + if (otrsBcc && !bccList.includes(otrsBcc)) bccList.push(otrsBcc); + } + + // Extract inline base64 images from bodyHtml and replace with CID references + const extractedInlineImages = []; + let processedBodyHtml = bodyHtml; + let cidCounter = 1; + processedBodyHtml = bodyHtml.replace(/src="data:([^;]+);base64,([^"]+)"/g, (match, contentType, base64Data) => { + const cid = `inline-image-${Date.now()}-${cidCounter++}`; + extractedInlineImages.push({ + cid, + content: base64Data, + contentType + }); + return `src="cid:${cid}"`; + }); + + const finalInlineImages = [...inlineImages, ...extractedInlineImages]; + + // Generate unique Message-ID + const messageId = `<${Date.now()}.${Math.random().toString(36).substring(2)}@pharmaidea.com>`; // 3. Send via configured mailer (Graph API or SMTP) - await sendMail({ to, cc, bcc, subject, bodyHtml, attachments, inlineImages }); + await sendMail({ to, cc, bcc: bccList, subject, bodyHtml: processedBodyHtml, attachments, inlineImages: finalInlineImages, inReplyTo, references, messageId }); - // 4. Log internal note in OTRS ticket via DB (email sent record) + // 4. Log article in OTRS ticket via DB as a standard Email article 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: [] }; + ? await pool.query(`SELECT login FROM users WHERE id = $1`, [agentId]) + : null; + const agentLogin = agentLoginResult?.rows[0]?.login || 'system'; - const agentUser = agentLoginResult.rows[0]; - let agentEmail = 'agent@localhost'; + // Query email from user_preferences for agent + let agentEmail = ''; 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; + if (prefRes.rows.length > 0) { + agentEmail = prefRes.rows[0].preferences_value || ''; } } - - const aFrom = agentUser - ? `"${agentUser.first_name} ${agentUser.last_name}" <${agentEmail}>` - : agentName; + const aFrom = agentEmail ? `"${agentLogin}" <${agentEmail}>` : `"${agentLogin}" <${process.env.AZURE_MAIL_SENDER || process.env.SMTP_FROM || 'helpdesk@example.com'}>`; 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) + // Insert article via DB metadata (Email channel=1, Visible to customer=1) 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 + is_visible_for_customer, create_time, create_by, change_time, change_by ) VALUES ( - $1, 1, 2, 0, $2, $3, $4, $5, - '/', $6, NOW(), $7, NOW(), $7 + $1, 1, 1, 1, NOW(), $2, NOW(), $2 ) RETURNING id`, - [ticketId, aFrom, toList, `[Email inviata] ${subject}`, noteBody, now, agentId || 1] + [ticketId, agentId || 1] ); const articleId = artInsert.rows[0]?.id; if (articleId) { + // Write standard HTML MIME data 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] + INSERT INTO article_data_mime (article_id, a_from, a_to, a_cc, a_bcc, a_subject, a_body, a_content_type, a_message_id, incoming_time, create_time, create_by, change_time, change_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'text/html; charset=utf-8', $8, $9, NOW(), $10, NOW(), $10)`, + [articleId, aFrom, toList, cc.join(', '), bccList.join(', '), subject, processedBodyHtml, messageId, now, agentId || 1] ); + + // Helper to strip HTML tags + const stripHtml = (html) => { + if (!html) return ''; + return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); + }; + + // Helper to clean search index values + const cleanSearchValue = (str) => { + if (!str) return ''; + return str.toLowerCase() + .replace(/[^\w\s@.+-]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + }; + + const plainBody = stripHtml(processedBodyHtml); + + // 1. Write standard plain text version for client fallbacks + await pool.query(` + INSERT INTO article_data_mime_plain (article_id, body, create_time, create_by, change_time, change_by) + VALUES ($1, $2, NOW(), $3, NOW(), $3)`, + [articleId, plainBody, agentId || 1] + ); + + // 2. Populate OTRS fulltext search index (article_search_index) + const indexRows = [ + { key: 'MIMEBase_From', val: aFrom }, + { key: 'MIMEBase_To', val: toList }, + { key: 'MIMEBase_Subject', val: subject }, + { key: 'MIMEBase_Body', val: plainBody } + ]; + if (cc && cc.length) { + indexRows.push({ key: 'MIMEBase_Cc', val: cc.join(', ') }); + } + + for (const row of indexRows) { + if (row.val) { + await pool.query(` + INSERT INTO article_search_index (ticket_id, article_id, article_key, article_value) + VALUES ($1, $2, $3, $4)`, + [ticketId, articleId, row.key, cleanSearchValue(row.val)] + ); + } + } + + // 3. Write attachments (the special 'file-1' HTML body, normal ones, and inline images) to article_data_mime_attachment + const allAtts = [ + { + filename: 'file-1', + contentType: 'text/html; charset="utf-8"', + content: Buffer.from(processedBodyHtml).toString('base64'), + disposition: '', + contentId: null + }, + ...attachments.map(a => ({ + filename: a.filename, + contentType: a.contentType || 'application/octet-stream', + content: a.content, // base64 + disposition: 'attachment', + contentId: null + })), + ...extractedInlineImages.map(img => ({ + filename: img.cid, + contentType: img.contentType || 'image/png', + content: img.content, // base64 + disposition: 'inline', + contentId: `<${img.cid}>` + })) + ]; + + for (const att of allAtts) { + try { + const byteSize = Buffer.from(att.content, 'base64').length; + await pool.query(` + INSERT INTO article_data_mime_attachment ( + article_id, filename, content_size, content_type, + content_id, disposition, content, + create_time, create_by, change_time, change_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, NOW(), $8)`, + [ + articleId, + att.filename, + byteSize, + att.contentType, + att.contentId, + att.disposition, + att.content, // base64 text directly + agentId || 1 + ] + ); + } catch (attErr) { + console.warn('[Email] Allegato non inserito:', attErr.message); + } + } } } catch (noteErr) { - console.warn('[Email] Nota interna OTRS non inserita (non bloccante):', noteErr.message); + console.warn('[Email] Articolo OTRS non inserito a database o non indicizzato (non bloccante):', noteErr.message); } console.log(`[Email] ✅ Email inviata per ticket #${tn} a: ${to.join(', ')}`); diff --git a/routes/tickets.js b/routes/tickets.js index 05568dc..8e95f57 100644 --- a/routes/tickets.js +++ b/routes/tickets.js @@ -1055,7 +1055,7 @@ router.get('/:id/articles', async (req, res) => { ast.name AS sender_type, cc.name AS channel_name, adm.a_from, adm.a_to, adm.a_cc, adm.a_subject, adm.a_body, - adm.a_content_type, adm.incoming_time, + adm.a_content_type, adm.incoming_time, adm.a_message_id, adm.a_references, a.create_time, creator.first_name AS creator_first, creator.last_name AS creator_last, ta.time_unit diff --git a/utils/graphMailer.js b/utils/graphMailer.js index 5f201c3..81b1038 100644 --- a/utils/graphMailer.js +++ b/utils/graphMailer.js @@ -60,7 +60,7 @@ async function getAccessToken() { * @param {Array} [options.inlineImages] - [{ cid, content (base64), contentType }] * @returns {Promise} */ -async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments = [], inlineImages = [] }) { +async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments = [], inlineImages = [], inReplyTo, references }) { const sender = process.env.AZURE_MAIL_SENDER; if (!sender) throw new Error('AZURE_MAIL_SENDER non configurato nel .env'); @@ -94,6 +94,14 @@ async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments })), ]; + const headers = []; + if (inReplyTo) { + headers.push({ name: 'In-Reply-To', value: inReplyTo }); + } + if (references) { + headers.push({ name: 'References', value: references }); + } + const payload = { message: { subject, @@ -105,6 +113,7 @@ async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments ccRecipients, bccRecipients, attachments: allAttachments, + internetMessageHeaders: headers.length ? headers : undefined, }, saveToSentItems: false, }; diff --git a/utils/smtpMailer.js b/utils/smtpMailer.js index 74ba280..6a78928 100644 --- a/utils/smtpMailer.js +++ b/utils/smtpMailer.js @@ -29,7 +29,7 @@ function getTransporter() { * Invia una email tramite SMTP (nodemailer). * Stessa interfaccia di graphMailer.sendMail. */ -async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments = [], inlineImages = [] }) { +async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments = [], inlineImages = [], inReplyTo, references }) { const transporter = getTransporter(); const mailOptions = { @@ -39,6 +39,8 @@ async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments bcc: bcc.length ? bcc.join(', ') : undefined, subject, html: bodyHtml, + inReplyTo, + references, attachments: [ ...attachments.map(a => ({ filename: a.filename,