fix: corretto invio da mail (scrittura diretta nel db). feat: aggiunta gestione di gruppi interni per l'invio delle mail
This commit is contained in:
@@ -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 {
|
try {
|
||||||
db.exec(`ALTER TABLE agent_settings ADD COLUMN tickets_per_page INTEGER NOT NULL DEFAULT 50`);
|
db.exec(`ALTER TABLE agent_settings ADD COLUMN tickets_per_page INTEGER NOT NULL DEFAULT 50`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
+3
-3
@@ -93,12 +93,12 @@
|
|||||||
</li>
|
</li>
|
||||||
<hr>
|
<hr>
|
||||||
<li>
|
<li>
|
||||||
<a href="#/signatures" class="nav-link" data-view="signatures" id="nav-signatures">
|
<a href="#/mail-management" class="nav-link" data-view="mail-management" id="nav-mail-management">
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
|
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
|
||||||
<polyline points="22,6 12,13 2,6" />
|
<polyline points="22,6 12,13 2,6" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Firme Email</span>
|
<span>Gestione Mail</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -197,7 +197,7 @@
|
|||||||
<script src="/js/views/ticketBulk.js"></script>
|
<script src="/js/views/ticketBulk.js"></script>
|
||||||
<script src="/js/views/activityLog.js"></script>
|
<script src="/js/views/activityLog.js"></script>
|
||||||
<script src="/js/views/emailCompose.js"></script>
|
<script src="/js/views/emailCompose.js"></script>
|
||||||
<script src="/js/views/signatures.js"></script>
|
<script src="/js/views/mailManagement.js"></script>
|
||||||
<script src="/js/views/ticketGroups.js"></script>
|
<script src="/js/views/ticketGroups.js"></script>
|
||||||
<script src="/js/app.js"></script>
|
<script src="/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+4
-4
@@ -152,10 +152,10 @@ const App = {
|
|||||||
titleEl.textContent = 'Storico Attività';
|
titleEl.textContent = 'Storico Attività';
|
||||||
ActivityLogView.render();
|
ActivityLogView.render();
|
||||||
|
|
||||||
} else if (hash === '#/signatures') {
|
} else if (hash === '#/mail-management') {
|
||||||
document.getElementById('nav-signatures')?.classList.add('active');
|
document.getElementById('nav-mail-management')?.classList.add('active');
|
||||||
titleEl.textContent = 'Firme Email';
|
titleEl.textContent = 'Gestione Mail';
|
||||||
SignaturesView.render();
|
MailManagementView.render();
|
||||||
|
|
||||||
} else if (hash.match(/^#\/tickets\/(\d+)$/)) {
|
} else if (hash.match(/^#\/tickets\/(\d+)$/)) {
|
||||||
const id = hash.match(/^#\/tickets\/(\d+)$/)[1];
|
const id = hash.match(/^#\/tickets\/(\d+)$/)[1];
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ const EmailCompose = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Tag Input Helper ──────────────────────────────────────────────────────────
|
// ── Tag Input Helper ──────────────────────────────────────────────────────────
|
||||||
function makeTagInput(containerId, initialEmails = []) {
|
function makeTagInput(containerId, initialEmails = [], onFocus = null) {
|
||||||
const container = document.getElementById(containerId);
|
const container = document.getElementById(containerId);
|
||||||
const tags = [...initialEmails];
|
const tags = [...initialEmails];
|
||||||
|
|
||||||
@@ -168,6 +168,11 @@ const EmailCompose = (() => {
|
|||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
input.placeholder = tags.length ? '' : 'email@esempio.com, premi Invio';
|
input.placeholder = tags.length ? '' : 'email@esempio.com, premi Invio';
|
||||||
input.value = currentVal;
|
input.value = currentVal;
|
||||||
|
|
||||||
|
if (onFocus) {
|
||||||
|
input.addEventListener('focus', onFocus);
|
||||||
|
}
|
||||||
|
|
||||||
input.addEventListener('keydown', (e) => {
|
input.addEventListener('keydown', (e) => {
|
||||||
if ((e.key === 'Enter' || e.key === ',') && input.value.trim()) {
|
if ((e.key === 'Enter' || e.key === ',') && input.value.trim()) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -221,6 +226,10 @@ const EmailCompose = (() => {
|
|||||||
<label class="ec-label">CC</label>
|
<label class="ec-label">CC</label>
|
||||||
<div class="ec-tags-input" id="ec-cc-container"></div>
|
<div class="ec-tags-input" id="ec-cc-container"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ec-field">
|
||||||
|
<label class="ec-label">BCC (CCN)</label>
|
||||||
|
<div class="ec-tags-input" id="ec-bcc-container"></div>
|
||||||
|
</div>
|
||||||
<div class="ec-field">
|
<div class="ec-field">
|
||||||
<label class="ec-label">Oggetto</label>
|
<label class="ec-label">Oggetto</label>
|
||||||
<input type="text" id="ec-subject" class="form-input" style="margin-bottom:0;" placeholder="Oggetto email" />
|
<input type="text" id="ec-subject" class="form-input" style="margin-bottom:0;" placeholder="Oggetto email" />
|
||||||
@@ -233,10 +242,16 @@ const EmailCompose = (() => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="ec-field" style="flex:1;">
|
<div class="ec-field" style="flex:1;">
|
||||||
<label class="ec-label">Tieni helpdesk in copia</label>
|
<label class="ec-label">Gruppi di Indirizzi</label>
|
||||||
|
<select id="ec-groups-select" class="ec-select" style="width:100%; min-width:unset;">
|
||||||
|
<option value="">— Inserisci gruppo... —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="ec-field" style="flex:1;">
|
||||||
|
<label class="ec-label">Helpdesk BCC (genera nuovo ticket)</label>
|
||||||
<select id="ec-helpdesk-cc-select" class="ec-select" style="width:100%; min-width:unset;">
|
<select id="ec-helpdesk-cc-select" class="ec-select" style="width:100%; min-width:unset;">
|
||||||
<option value="1">Sì (BCC automatico)</option>
|
<option value="0">No (BCC automatico)</option>
|
||||||
<option value="0">No</option>
|
<option value="1">Sì</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -310,6 +325,23 @@ const EmailCompose = (() => {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Load Address Groups ────────────────────────────────────────────────────────
|
||||||
|
async function loadAddressGroups(agentId, selectEl) {
|
||||||
|
try {
|
||||||
|
const groups = await App.api(`/api/email/address-groups?agent_id=${agentId}`);
|
||||||
|
selectEl.innerHTML = '<option value="">— Inserisci gruppo... —</option>';
|
||||||
|
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 ─────────────────────────────────────────────────────────────────────
|
// ── Open ─────────────────────────────────────────────────────────────────────
|
||||||
async function open(options = {}) {
|
async function open(options = {}) {
|
||||||
injectStyles();
|
injectStyles();
|
||||||
@@ -323,11 +355,14 @@ const EmailCompose = (() => {
|
|||||||
const overlay = buildModal();
|
const overlay = buildModal();
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
// Init tag inputs
|
// Init tag inputs with focus tracking
|
||||||
const initialTo = options.initialTo || (options.customerEmail ? [options.customerEmail] : []);
|
const initialTo = options.initialTo || (options.customerEmail ? [options.customerEmail] : []);
|
||||||
const initialCc = options.initialCc || [];
|
const initialCc = options.initialCc || [];
|
||||||
const toTagsCtrl = makeTagInput('ec-to-container', initialTo);
|
let lastFocusedCtrl = null;
|
||||||
const ccTagsCtrl = makeTagInput('ec-cc-container', initialCc);
|
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
|
// Subject
|
||||||
const subjectEl = document.getElementById('ec-subject');
|
const subjectEl = document.getElementById('ec-subject');
|
||||||
@@ -335,10 +370,27 @@ const EmailCompose = (() => {
|
|||||||
const title = options.ticketTitle || '';
|
const title = options.ticketTitle || '';
|
||||||
subjectEl.value = tn ? `Re: [Ticket#${tn}] ${title}` : title;
|
subjectEl.value = tn ? `Re: [Ticket#${tn}] ${title}` : title;
|
||||||
|
|
||||||
// Signature select
|
// Signature and groups select
|
||||||
const sigSelect = document.getElementById('ec-signature-select');
|
const sigSelect = document.getElementById('ec-signature-select');
|
||||||
|
const groupsSelect = document.getElementById('ec-groups-select');
|
||||||
const agentId = App.currentAgentId || 0;
|
const agentId = App.currentAgentId || 0;
|
||||||
const defaultSigHtml = await loadSignatures(agentId, sigSelect);
|
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
|
// Quill editor
|
||||||
quillEditor = new Quill('#ec-quill-editor', {
|
quillEditor = new Quill('#ec-quill-editor', {
|
||||||
@@ -405,7 +457,7 @@ const EmailCompose = (() => {
|
|||||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||||
|
|
||||||
// Send
|
// Send
|
||||||
document.getElementById('ec-send').addEventListener('click', () => sendEmail(toTagsCtrl, ccTagsCtrl));
|
document.getElementById('ec-send').addEventListener('click', () => sendEmail(toTagsCtrl, ccTagsCtrl, bccTagsCtrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
function processFiles(files) {
|
function processFiles(files) {
|
||||||
@@ -421,9 +473,10 @@ const EmailCompose = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Send ─────────────────────────────────────────────────────────────────────
|
// ── Send ─────────────────────────────────────────────────────────────────────
|
||||||
async function sendEmail(toCtrl, ccCtrl) {
|
async function sendEmail(toCtrl, ccCtrl, bccCtrl) {
|
||||||
const to = toCtrl.getTags();
|
const to = toCtrl.getTags();
|
||||||
const cc = ccCtrl.getTags();
|
const cc = ccCtrl.getTags();
|
||||||
|
const bcc = bccCtrl.getTags();
|
||||||
const subject = document.getElementById('ec-subject').value.trim();
|
const subject = document.getElementById('ec-subject').value.trim();
|
||||||
const bodyHtml = quillEditor ? quillEditor.root.innerHTML : '';
|
const bodyHtml = quillEditor ? quillEditor.root.innerHTML : '';
|
||||||
|
|
||||||
@@ -438,10 +491,12 @@ const EmailCompose = (() => {
|
|||||||
const agentId = App.currentAgentId || 0;
|
const agentId = App.currentAgentId || 0;
|
||||||
const payload = {
|
const payload = {
|
||||||
ticketId: currentOptions.ticketId,
|
ticketId: currentOptions.ticketId,
|
||||||
to, cc, subject, bodyHtml,
|
to, cc, bcc, subject, bodyHtml,
|
||||||
attachments: attachmentsList,
|
attachments: attachmentsList,
|
||||||
agentId,
|
agentId,
|
||||||
keepHelpdeskCopy: document.getElementById('ec-helpdesk-cc-select').value === '1',
|
keepHelpdeskCopy: document.getElementById('ec-helpdesk-cc-select').value === '1',
|
||||||
|
inReplyTo: currentOptions.inReplyTo,
|
||||||
|
references: currentOptions.references,
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await App.api('/api/email/send', {
|
const res = await App.api('/api/email/send', {
|
||||||
|
|||||||
@@ -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 = `
|
||||||
|
<div style="max-width: 820px; margin: 0 auto; padding: var(--space-xl) var(--space-lg); display: flex; flex-direction: column; gap: var(--space-xl);">
|
||||||
|
|
||||||
|
<!-- SECTION 1: Address Groups -->
|
||||||
|
<div class="card" style="padding: var(--space-lg);">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-md);">
|
||||||
|
<div>
|
||||||
|
<h2 style="margin:0; font-size:1.15rem; font-weight:700; color:var(--text-primary); display:flex; align-items:center; gap:8px;">👥 Gruppi di Indirizzi</h2>
|
||||||
|
<p style="margin:4px 0 0; font-size:0.82rem; color:var(--text-muted);">Crea gruppi di contatti da inserire rapidamente nei campi A, CC o BCC.</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" id="btn-new-group">
|
||||||
|
+ Nuovo Gruppo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="groups-list" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: var(--space-md);"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SECTION 2: Signatures -->
|
||||||
|
<div class="card" style="padding: var(--space-lg);">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-md);">
|
||||||
|
<div>
|
||||||
|
<h2 style="margin:0; font-size:1.15rem; font-weight:700; color:var(--text-primary); display:flex; align-items:center; gap:8px;">✉️ Le Mie Firme Email</h2>
|
||||||
|
<p style="margin:4px 0 0; font-size:0.82rem; color:var(--text-muted);">Gestisci le firme da allegare automaticamente alle tue email.</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" id="btn-new-signature">
|
||||||
|
+ Nuova Firma
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="signatures-list" style="display:flex; flex-direction:column; gap:var(--space-md);"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MODAL: Signature Editor -->
|
||||||
|
<div id="signature-editor-modal" style="display:none; position:fixed; inset:0; z-index:8000; background:rgba(0,0,0,0.5); backdrop-filter:blur(4px); align-items:center; justify-content:center;">
|
||||||
|
<div style="background:var(--bg-card); border:1px solid var(--border-light); border-radius:var(--radius-xl); box-shadow:0 24px 80px rgba(0,0,0,0.3); width:min(720px,94vw); max-height:90vh; display:flex; flex-direction:column;">
|
||||||
|
<div style="padding:16px 20px 12px; border-bottom:1px solid var(--border-subtle); display:flex; align-items:center; justify-content:space-between; flex-shrink:0;">
|
||||||
|
<div style="font-size:1rem; font-weight:600; color:var(--text-primary);" id="sig-modal-title">Nuova Firma</div>
|
||||||
|
<button id="sig-modal-close" style="background:none;border:none;cursor:pointer;color:var(--text-muted);font-size:1.2rem;">✕</button>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px 20px; flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:12px;">
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.75rem; font-weight:600; color:var(--text-secondary); text-transform:uppercase; letter-spacing:0.04em;">Nome Firma</label>
|
||||||
|
<input type="text" id="sig-name" class="form-input" placeholder="es. Firma Professionale" style="margin-bottom:0; margin-top:4px;" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.75rem; font-weight:600; color:var(--text-secondary); text-transform:uppercase; letter-spacing:0.04em;">Contenuto</label>
|
||||||
|
<div style="margin-top:4px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); overflow:hidden; background:var(--bg-tertiary);">
|
||||||
|
<div id="sig-quill-editor" style="min-height:200px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label style="display:flex; align-items:center; gap:8px; font-size:0.88rem; color:var(--text-secondary); cursor:pointer;">
|
||||||
|
<input type="checkbox" id="sig-is-default" />
|
||||||
|
Imposta come firma predefinita
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style="padding:12px 20px; border-top:1px solid var(--border-subtle); display:flex; justify-content:flex-end; gap:10px; flex-shrink:0;">
|
||||||
|
<button class="btn btn-ghost btn-sm" id="sig-cancel">Annulla</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="sig-save">Salva Firma</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MODAL: Address Group Editor -->
|
||||||
|
<div id="group-editor-modal" style="display:none; position:fixed; inset:0; z-index:8000; background:rgba(0,0,0,0.5); backdrop-filter:blur(4px); align-items:center; justify-content:center;">
|
||||||
|
<div style="background:var(--bg-card); border:1px solid var(--border-light); border-radius:var(--radius-xl); box-shadow:0 24px 80px rgba(0,0,0,0.3); width:min(520px,94vw); max-height:90vh; display:flex; flex-direction:column;">
|
||||||
|
<div style="padding:16px 20px 12px; border-bottom:1px solid var(--border-subtle); display:flex; align-items:center; justify-content:space-between; flex-shrink:0;">
|
||||||
|
<div style="font-size:1rem; font-weight:600; color:var(--text-primary);" id="group-modal-title">Nuovo Gruppo</div>
|
||||||
|
<button id="group-modal-close" style="background:none;border:none;cursor:pointer;color:var(--text-muted);font-size:1.2rem;">✕</button>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px 20px; flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:12px;">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<label style="font-size:0.75rem; font-weight:600; color:var(--text-secondary); text-transform:uppercase; letter-spacing:0.04em;">Nome Gruppo</label>
|
||||||
|
<input type="text" id="group-name" class="form-input" placeholder="es. Sviluppo Interno" style="width: 100%; box-sizing: border-box; display: block; margin-bottom:0;" />
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<label style="font-size:0.75rem; font-weight:600; color:var(--text-secondary); text-transform:uppercase; letter-spacing:0.04em;">Indirizzi Email (separati da virgola)</label>
|
||||||
|
<textarea id="group-emails" class="form-input" rows="4" placeholder="es. user1@test.com, user2@test.com" style="width: 100%; box-sizing: border-box; display: block; margin-bottom:0; resize:vertical; font-family:monospace; font-size:0.85rem; padding:8px 12px;"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:12px 20px; border-top:1px solid var(--border-subtle); display:flex; justify-content:flex-end; gap:10px; flex-shrink:0;">
|
||||||
|
<button class="btn btn-ghost btn-sm" id="group-cancel">Annulla</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="group-save">Salva Gruppo</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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 = `
|
||||||
|
<div class="empty-state" style="padding:var(--space-md); border:1px dashed var(--border-subtle); border-radius:var(--radius-md);">
|
||||||
|
<div class="empty-state-icon" style="font-size:1.5rem;">✉️</div>
|
||||||
|
<div class="empty-state-text" style="font-size:0.9rem;">Nessuna firma configurata</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = this.signatures.map(sig => `
|
||||||
|
<div class="card" style="padding: var(--space-md); background: var(--bg-secondary); border-color: var(--border-subtle);">
|
||||||
|
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:${sig.body_html ? 'var(--space-sm)' : '0'};">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600; font-size:0.9rem; color:var(--text-primary); display:flex; align-items:center; gap:8px;">
|
||||||
|
${App.escapeHtml(sig.name)}
|
||||||
|
${sig.is_default ? '<span style="font-size:0.68rem; background:var(--accent-primary); color:#fff; padding:2px 7px; border-radius:20px;">Predefinita</span>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:6px; flex-shrink:0;">
|
||||||
|
${!sig.is_default ? `<button class="btn btn-ghost btn-sm sig-btn-default" data-id="${sig.id}" style="height:26px; font-size:0.75rem; padding:0 8px;">★ Predefinita</button>` : ''}
|
||||||
|
<button class="btn btn-ghost btn-sm sig-btn-edit" data-id="${sig.id}" style="height:26px; font-size:0.75rem; padding:0 8px;">✏️ Modifica</button>
|
||||||
|
<button class="btn btn-ghost btn-sm sig-btn-delete" data-id="${sig.id}" style="height:26px; font-size:0.75rem; padding:0 8px; color:var(--error);">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${sig.body_html ? `
|
||||||
|
<div style="border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:8px 12px; background:var(--bg-tertiary); max-height:80px; overflow:hidden; position:relative;">
|
||||||
|
<div style="font-size:0.8rem; color:var(--text-secondary);">${sig.body_html}</div>
|
||||||
|
<div style="position:absolute;bottom:0;left:0;right:0;height:24px;background:linear-gradient(transparent,var(--bg-tertiary));"></div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`).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 = `
|
||||||
|
<div class="empty-state" style="padding:var(--space-md); border:1px dashed var(--border-subtle); border-radius:var(--radius-md); grid-column: 1 / -1;">
|
||||||
|
<div class="empty-state-icon" style="font-size:1.5rem;">👥</div>
|
||||||
|
<div class="empty-state-text" style="font-size:0.9rem;">Nessun gruppo configurato</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 `
|
||||||
|
<div class="g-card">
|
||||||
|
<div class="g-tooltip"><strong>Contatti (${count}):</strong>\n${App.escapeHtml(tooltipText)}</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600; font-size:0.9rem; color:var(--text-primary);">${App.escapeHtml(g.name)}</div>
|
||||||
|
<div style="font-size:0.75rem; color:var(--text-muted); margin-top:2px;">
|
||||||
|
${count} indirizz${count === 1 ? 'o' : 'i'} email
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:6px; justify-content: flex-end; margin-top:4px;">
|
||||||
|
<button class="btn btn-ghost btn-sm group-btn-edit" data-id="${g.id}" style="height:24px; font-size:0.72rem; padding:0 6px;">✏️</button>
|
||||||
|
<button class="btn btn-ghost btn-sm group-btn-delete" data-id="${g.id}" style="height:24px; font-size:0.72rem; padding:0 6px; color:var(--error);">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).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;
|
||||||
@@ -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 = `
|
|
||||||
<div style="max-width: 820px; margin: 0 auto; padding: var(--space-xl) var(--space-lg);">
|
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-xl);">
|
|
||||||
<div>
|
|
||||||
<h2 style="margin:0; font-size:1.25rem; font-weight:700; color:var(--text-primary);">✉️ Le Mie Firme Email</h2>
|
|
||||||
<p style="margin:4px 0 0; font-size:0.85rem; color:var(--text-muted);">Gestisci le firme da allegare automaticamente alle email inviate dal ticket.</p>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary btn-sm" id="btn-new-signature">
|
|
||||||
+ Nuova Firma
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Lista firme -->
|
|
||||||
<div id="signatures-list" style="display:flex; flex-direction:column; gap:var(--space-md);"></div>
|
|
||||||
|
|
||||||
<!-- Modal Editor -->
|
|
||||||
<div id="signature-editor-modal" style="display:none; position:fixed; inset:0; z-index:8000; background:rgba(0,0,0,0.5); backdrop-filter:blur(4px); align-items:center; justify-content:center;">
|
|
||||||
<div style="background:var(--bg-card); border:1px solid var(--border-light); border-radius:var(--radius-xl); box-shadow:0 24px 80px rgba(0,0,0,0.3); width:min(720px,94vw); max-height:90vh; display:flex; flex-direction:column;">
|
|
||||||
<div style="padding:16px 20px 12px; border-bottom:1px solid var(--border-subtle); display:flex; align-items:center; justify-content:space-between; flex-shrink:0;">
|
|
||||||
<div style="font-size:1rem; font-weight:600; color:var(--text-primary);" id="sig-modal-title">Nuova Firma</div>
|
|
||||||
<button id="sig-modal-close" style="background:none;border:none;cursor:pointer;color:var(--text-muted);font-size:1.2rem;">✕</button>
|
|
||||||
</div>
|
|
||||||
<div style="padding:16px 20px; flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:12px;">
|
|
||||||
<div>
|
|
||||||
<label style="font-size:0.75rem; font-weight:600; color:var(--text-secondary); text-transform:uppercase; letter-spacing:0.04em;">Nome Firma</label>
|
|
||||||
<input type="text" id="sig-name" class="form-input" placeholder="es. Firma Professionale" style="margin-bottom:0; margin-top:4px;" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label style="font-size:0.75rem; font-weight:600; color:var(--text-secondary); text-transform:uppercase; letter-spacing:0.04em;">Contenuto</label>
|
|
||||||
<div style="margin-top:4px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); overflow:hidden; background:var(--bg-tertiary);">
|
|
||||||
<div id="sig-quill-editor" style="min-height:200px;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label style="display:flex; align-items:center; gap:8px; font-size:0.88rem; color:var(--text-secondary); cursor:pointer;">
|
|
||||||
<input type="checkbox" id="sig-is-default" />
|
|
||||||
Imposta come firma predefinita
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div style="padding:12px 20px; border-top:1px solid var(--border-subtle); display:flex; justify-content:flex-end; gap:10px; flex-shrink:0;">
|
|
||||||
<button class="btn btn-ghost btn-sm" id="sig-cancel">Annulla</button>
|
|
||||||
<button class="btn btn-primary btn-sm" id="sig-save">Salva Firma</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
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 = `
|
|
||||||
<div class="empty-state">
|
|
||||||
<div class="empty-state-icon">✉️</div>
|
|
||||||
<div class="empty-state-text">Nessuna firma configurata</div>
|
|
||||||
<div class="empty-state-sub">Crea la tua prima firma per velocizzare l'invio delle email.</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
list.innerHTML = this.signatures.map(sig => `
|
|
||||||
<div class="card" style="padding: var(--space-lg);">
|
|
||||||
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:${sig.body_html ? 'var(--space-md)' : '0'};">
|
|
||||||
<div>
|
|
||||||
<div style="font-weight:600; font-size:0.95rem; color:var(--text-primary); display:flex; align-items:center; gap:8px;">
|
|
||||||
${App.escapeHtml(sig.name)}
|
|
||||||
${sig.is_default ? '<span style="font-size:0.72rem; background:var(--accent-primary); color:#fff; padding:2px 7px; border-radius:20px;">Predefinita</span>' : ''}
|
|
||||||
</div>
|
|
||||||
<div style="font-size:0.72rem; color:var(--text-muted); margin-top:2px;">
|
|
||||||
Creata: ${new Date(sig.created_at).toLocaleDateString('it-IT')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex; gap:6px; flex-shrink:0;">
|
|
||||||
${!sig.is_default ? `<button class="btn btn-ghost btn-sm sig-btn-default" data-id="${sig.id}" style="height:30px; font-size:0.78rem;">★ Predefinita</button>` : ''}
|
|
||||||
<button class="btn btn-ghost btn-sm sig-btn-edit" data-id="${sig.id}" style="height:30px; font-size:0.78rem;">✏️ Modifica</button>
|
|
||||||
<button class="btn btn-ghost btn-sm sig-btn-delete" data-id="${sig.id}" style="height:30px; font-size:0.78rem; color:var(--error);">🗑️</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
${sig.body_html ? `
|
|
||||||
<div style="border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:10px 14px; background:var(--bg-secondary); max-height:120px; overflow:hidden; position:relative;">
|
|
||||||
<div style="font-size:0.82rem; color:var(--text-secondary);">${sig.body_html}</div>
|
|
||||||
<div style="position:absolute;bottom:0;left:0;right:0;height:40px;background:linear-gradient(transparent,var(--bg-secondary));"></div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
`).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;
|
|
||||||
@@ -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({
|
EmailCompose.open({
|
||||||
ticketId: ticket.id,
|
ticketId: ticket.id,
|
||||||
ticketTn: ticket.tn,
|
ticketTn: ticket.tn,
|
||||||
@@ -797,6 +805,8 @@ const TicketDetailView = {
|
|||||||
initialTo: Array.from(initialToSet),
|
initialTo: Array.from(initialToSet),
|
||||||
initialCc: Array.from(initialCcSet),
|
initialCc: Array.from(initialCcSet),
|
||||||
initialBodyHtml: initialBodyHtml,
|
initialBodyHtml: initialBodyHtml,
|
||||||
|
inReplyTo: inReplyTo,
|
||||||
|
references: references,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
Toast.error('Modulo email non disponibile');
|
Toast.error('Modulo email non disponibile');
|
||||||
|
|||||||
+242
-53
@@ -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 ───────────────────────────────────────────────────────────────
|
// ─── SEND EMAIL ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// POST /api/email/send — send an email for a ticket
|
// POST /api/email/send — send an email for a ticket
|
||||||
router.post('/send', async (req, res) => {
|
router.post('/send', async (req, res) => {
|
||||||
const {
|
const {
|
||||||
ticketId,
|
ticketId,
|
||||||
to,
|
to,
|
||||||
cc = [],
|
cc = [],
|
||||||
subject: customSubject,
|
bcc = [],
|
||||||
bodyHtml,
|
subject: customSubject,
|
||||||
attachments = [],
|
bodyHtml,
|
||||||
inlineImages = [],
|
attachments = [],
|
||||||
agentId,
|
inlineImages = [],
|
||||||
agentName = 'Agente',
|
agentId,
|
||||||
keepHelpdeskCopy = true,
|
agentName = 'Agente',
|
||||||
} = req.body;
|
keepHelpdeskCopy = true,
|
||||||
|
inReplyTo,
|
||||||
|
references,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
if (!ticketId) return res.status(400).json({ error: 'ticketId è 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 (!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 (!bodyHtml) return res.status(400).json({ error: 'Il corpo della email è obbligatorio' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Fetch ticket number and title for subject
|
// 1. Fetch ticket number and title for subject
|
||||||
const ticketResult = await pool.query(
|
const ticketResult = await pool.query(
|
||||||
`SELECT tn, title FROM ticket WHERE id = $1`,
|
`SELECT tn, title FROM ticket WHERE id = $1`,
|
||||||
[ticketId]
|
[ticketId]
|
||||||
);
|
);
|
||||||
if (!ticketResult.rows.length) return res.status(404).json({ error: 'Ticket non trovato' });
|
if (!ticketResult.rows.length) return res.status(404).json({ error: 'Ticket non trovato' });
|
||||||
|
|
||||||
const { tn, title } = ticketResult.rows[0];
|
const { tn, title } = ticketResult.rows[0];
|
||||||
const subject = customSubject || `Re: [Ticket#${tn}] ${title}`;
|
const subject = customSubject || `Re: [Ticket#${tn}] ${title}`;
|
||||||
|
|
||||||
// 2. Build BCC list (include OTRS system mailbox if keepHelpdeskCopy is true)
|
// 2. Build BCC list (include OTRS system mailbox if keepHelpdeskCopy is true)
|
||||||
const bcc = [];
|
const bccList = [...bcc];
|
||||||
if (keepHelpdeskCopy) {
|
if (keepHelpdeskCopy) {
|
||||||
const otrsBcc = process.env.OTRS_MAIL_BCC;
|
const otrsBcc = process.env.OTRS_MAIL_BCC;
|
||||||
if (otrsBcc) bcc.push(otrsBcc);
|
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)
|
// 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 {
|
try {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const agentLoginResult = agentId
|
const agentLoginResult = agentId
|
||||||
? await pool.query(`SELECT login, first_name, last_name FROM users WHERE id = $1`, [agentId])
|
? await pool.query(`SELECT login FROM users WHERE id = $1`, [agentId])
|
||||||
: { rows: [] };
|
: null;
|
||||||
|
const agentLogin = agentLoginResult?.rows[0]?.login || 'system';
|
||||||
|
|
||||||
const agentUser = agentLoginResult.rows[0];
|
// Query email from user_preferences for agent
|
||||||
let agentEmail = 'agent@localhost';
|
let agentEmail = '';
|
||||||
if (agentId) {
|
if (agentId) {
|
||||||
const prefRes = await pool.query(
|
const prefRes = await pool.query(
|
||||||
`SELECT preferences_value FROM user_preferences WHERE user_id = $1 AND preferences_key = 'UserEmail'`,
|
`SELECT preferences_value FROM user_preferences WHERE user_id = $1 AND preferences_key = 'UserEmail'`,
|
||||||
[agentId]
|
[agentId]
|
||||||
);
|
);
|
||||||
if (prefRes.rows.length > 0 && prefRes.rows[0].preferences_value) {
|
if (prefRes.rows.length > 0) {
|
||||||
agentEmail = prefRes.rows[0].preferences_value;
|
agentEmail = prefRes.rows[0].preferences_value || '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const aFrom = agentEmail ? `"${agentLogin}" <${agentEmail}>` : `"${agentLogin}" <${process.env.AZURE_MAIL_SENDER || process.env.SMTP_FROM || 'helpdesk@example.com'}>`;
|
||||||
const aFrom = agentUser
|
|
||||||
? `"${agentUser.first_name} ${agentUser.last_name}" <${agentEmail}>`
|
|
||||||
: agentName;
|
|
||||||
|
|
||||||
const toList = to.join(', ');
|
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(`
|
const artInsert = await pool.query(`
|
||||||
INSERT INTO article (
|
INSERT INTO article (
|
||||||
ticket_id, article_sender_type_id, communication_channel_id,
|
ticket_id, article_sender_type_id, communication_channel_id,
|
||||||
is_visible_for_customer, a_from, a_to, a_subject, a_body,
|
is_visible_for_customer, create_time, create_by, change_time, change_by
|
||||||
content_path, incoming_time, create_time, create_by, change_time, change_by
|
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, 1, 2, 0, $2, $3, $4, $5,
|
$1, 1, 1, 1, NOW(), $2, NOW(), $2
|
||||||
'/', $6, NOW(), $7, NOW(), $7
|
|
||||||
) RETURNING id`,
|
) RETURNING id`,
|
||||||
[ticketId, aFrom, toList, `[Email inviata] ${subject}`, noteBody, now, agentId || 1]
|
[ticketId, agentId || 1]
|
||||||
);
|
);
|
||||||
|
|
||||||
const articleId = artInsert.rows[0]?.id;
|
const articleId = artInsert.rows[0]?.id;
|
||||||
|
|
||||||
if (articleId) {
|
if (articleId) {
|
||||||
|
// Write standard HTML MIME data
|
||||||
await pool.query(`
|
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)
|
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, 'text/plain; charset=utf-8', $7, NOW(), $8, NOW(), $8)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, 'text/html; charset=utf-8', $8, $9, NOW(), $10, NOW(), $10)`,
|
||||||
[articleId, aFrom, toList, cc.join(', '), subject, noteBody, now, agentId || 1]
|
[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) {
|
} 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(', ')}`);
|
console.log(`[Email] ✅ Email inviata per ticket #${tn} a: ${to.join(', ')}`);
|
||||||
|
|||||||
+1
-1
@@ -1055,7 +1055,7 @@ router.get('/:id/articles', async (req, res) => {
|
|||||||
ast.name AS sender_type,
|
ast.name AS sender_type,
|
||||||
cc.name AS channel_name,
|
cc.name AS channel_name,
|
||||||
adm.a_from, adm.a_to, adm.a_cc, adm.a_subject, adm.a_body,
|
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,
|
a.create_time,
|
||||||
creator.first_name AS creator_first, creator.last_name AS creator_last,
|
creator.first_name AS creator_first, creator.last_name AS creator_last,
|
||||||
ta.time_unit
|
ta.time_unit
|
||||||
|
|||||||
+10
-1
@@ -60,7 +60,7 @@ async function getAccessToken() {
|
|||||||
* @param {Array} [options.inlineImages] - [{ cid, content (base64), contentType }]
|
* @param {Array} [options.inlineImages] - [{ cid, content (base64), contentType }]
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
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;
|
const sender = process.env.AZURE_MAIL_SENDER;
|
||||||
if (!sender) throw new Error('AZURE_MAIL_SENDER non configurato nel .env');
|
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 = {
|
const payload = {
|
||||||
message: {
|
message: {
|
||||||
subject,
|
subject,
|
||||||
@@ -105,6 +113,7 @@ async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments
|
|||||||
ccRecipients,
|
ccRecipients,
|
||||||
bccRecipients,
|
bccRecipients,
|
||||||
attachments: allAttachments,
|
attachments: allAttachments,
|
||||||
|
internetMessageHeaders: headers.length ? headers : undefined,
|
||||||
},
|
},
|
||||||
saveToSentItems: false,
|
saveToSentItems: false,
|
||||||
};
|
};
|
||||||
|
|||||||
+3
-1
@@ -29,7 +29,7 @@ function getTransporter() {
|
|||||||
* Invia una email tramite SMTP (nodemailer).
|
* Invia una email tramite SMTP (nodemailer).
|
||||||
* Stessa interfaccia di graphMailer.sendMail.
|
* 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 transporter = getTransporter();
|
||||||
|
|
||||||
const mailOptions = {
|
const mailOptions = {
|
||||||
@@ -39,6 +39,8 @@ async function sendMail({ to, cc = [], bcc = [], subject, bodyHtml, attachments
|
|||||||
bcc: bcc.length ? bcc.join(', ') : undefined,
|
bcc: bcc.length ? bcc.join(', ') : undefined,
|
||||||
subject,
|
subject,
|
||||||
html: bodyHtml,
|
html: bodyHtml,
|
||||||
|
inReplyTo,
|
||||||
|
references,
|
||||||
attachments: [
|
attachments: [
|
||||||
...attachments.map(a => ({
|
...attachments.map(a => ({
|
||||||
filename: a.filename,
|
filename: a.filename,
|
||||||
|
|||||||
Reference in New Issue
Block a user