diff --git a/public/js/views/ticketDetail.js b/public/js/views/ticketDetail.js
index f1f7dbf..80b5f6c 100644
--- a/public/js/views/ticketDetail.js
+++ b/public/js/views/ticketDetail.js
@@ -49,6 +49,41 @@ const TicketDetailView = {
});
},
+ openImageLightbox(src) {
+ let overlay = document.getElementById('image-lightbox-overlay');
+ if (!overlay) {
+ overlay = document.createElement('div');
+ overlay.id = 'image-lightbox-overlay';
+ overlay.style.cssText = `
+ position: fixed;
+ inset: 0;
+ z-index: 10000;
+ background: rgba(0, 0, 0, 0.85);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: zoom-out;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ `;
+ overlay.innerHTML = `
+
+ `;
+ overlay.addEventListener('click', () => {
+ overlay.style.opacity = '0';
+ setTimeout(() => overlay.remove(), 200);
+ });
+ overlay.querySelector('img').addEventListener('click', (e) => {
+ e.stopPropagation();
+ });
+ document.body.appendChild(overlay);
+ }
+ const imgEl = overlay.querySelector('img');
+ imgEl.src = src;
+ overlay.offsetHeight;
+ overlay.style.opacity = '1';
+ },
+
async render(id) {
this.ticketId = id;
this.noteAttachments = [];
@@ -252,11 +287,33 @@ const TicketDetailView = {
${articles.length > 0 ? articles.map(a => {
const hasHtml = (a.a_content_type || '').toLowerCase().includes('html') || a.a_body.includes('') || a.a_body.includes('/>');
+
+ let processedBody = a.a_body || '';
+ const articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id);
+
+ if (hasHtml) {
+ articleAttachments.forEach(att => {
+ if (att.content_id) {
+ const cleanCid = att.content_id.replace(/[<>]/g, '').trim();
+ if (cleanCid) {
+ const escapedCid = cleanCid.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
+ const regex = new RegExp(`cid:${escapedCid}>?`, 'gi');
+ processedBody = processedBody.replace(regex, `/api/tickets/attachments/${att.id}`);
+ }
+ }
+ if (att.filename) {
+ const escapedFilename = att.filename.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
+ const regexFilename = new RegExp(`cid:${escapedFilename}>?`, 'gi');
+ processedBody = processedBody.replace(regexFilename, `/api/tickets/attachments/${att.id}`);
+ }
+ });
+ }
+
const displayBody = this.htmlMode && hasHtml
- ? `
`
+ ? `
`
: `
${App.escapeHtml(a.a_body || '')}
`;
- const articleAttachments = (attachments || []).filter(att => att.article_id === a.article_id && att.filename !== 'file-1' && att.filename !== 'file-2');
+ const visibleAttachments = articleAttachments.filter(att => att.filename !== 'file-1' && att.filename !== 'file-2');
return `
@@ -301,10 +358,10 @@ const TicketDetailView = {
${a.a_subject ? `
${App.escapeHtml(a.a_subject)}
` : ''}
${displayBody}
- ${articleAttachments.length > 0 ? `
+ ${visibleAttachments.length > 0 ? `
- ${articleAttachments.map(att => `
-
+ ${visibleAttachments.map(att => `
+
📎
${App.escapeHtml(att.filename)}
(${Math.round(att.content_size / 1024)} KB)
@@ -882,7 +939,28 @@ const TicketDetailView = {
if (window.EmailCompose) {
const hasHtml = (article.a_content_type || '').toLowerCase().includes('html') || article.a_body.includes('') || article.a_body.includes('/>');
- const quotedBody = hasHtml ? article.a_body : App.escapeHtml(article.a_body || '').replace(/\n/g, '
');
+
+ let processedQuoted = article.a_body || '';
+ if (hasHtml) {
+ const articleAttachments = (attachments || []).filter(att => att.article_id === article.article_id);
+ articleAttachments.forEach(att => {
+ if (att.content_id) {
+ const cleanCid = att.content_id.replace(/[<>]/g, '').trim();
+ if (cleanCid) {
+ const escapedCid = cleanCid.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
+ const regex = new RegExp(`cid:${escapedCid}>?`, 'gi');
+ processedQuoted = processedQuoted.replace(regex, `/api/tickets/attachments/${att.id}`);
+ }
+ }
+ if (att.filename) {
+ const escapedFilename = att.filename.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
+ const regexFilename = new RegExp(`cid:${escapedFilename}>?`, 'gi');
+ processedQuoted = processedQuoted.replace(regexFilename, `/api/tickets/attachments/${att.id}`);
+ }
+ });
+ }
+
+ const quotedBody = hasHtml ? processedQuoted : App.escapeHtml(article.a_body || '').replace(/\n/g, '
');
const initialBodyHtml = `
@@ -1188,5 +1266,41 @@ const TicketDetailView = {
}
});
}
+
+ // Inline image preview & lightbox binding
+ document.querySelectorAll('.articles-timeline iframe').forEach(iframe => {
+ const attachImageClick = () => {
+ try {
+ const doc = iframe.contentDocument || iframe.contentWindow.document;
+ if (doc) {
+ doc.querySelectorAll('img').forEach(img => {
+ img.style.cursor = 'zoom-in';
+ img.addEventListener('click', (e) => {
+ e.preventDefault();
+ this.openImageLightbox(img.src);
+ });
+ });
+ }
+ } catch (err) {
+ console.warn('Cannot attach click listeners to iframe images', err);
+ }
+ };
+
+ iframe.addEventListener('load', attachImageClick);
+ try {
+ const doc = iframe.contentDocument || iframe.contentWindow.document;
+ if (doc && doc.readyState === 'complete') {
+ attachImageClick();
+ }
+ } catch (_) {}
+ });
+
+ document.querySelectorAll('.attachment-badge[data-is-image="true"]').forEach(badge => {
+ badge.style.cursor = 'zoom-in';
+ badge.addEventListener('click', (e) => {
+ e.preventDefault();
+ this.openImageLightbox(badge.href);
+ });
+ });
},
};
diff --git a/routes/email.js b/routes/email.js
index b4cffd3..986ce9f 100644
--- a/routes/email.js
+++ b/routes/email.js
@@ -6,6 +6,13 @@ const pool = require('../db');
const { db } = require('../activityDb');
const { sendMail } = require('../utils/mailer');
+// Helper to get local timestamp in YYYY-MM-DD HH:mm:ss format
+function getLocalTimestamp() {
+ const d = new Date();
+ const pad = (n) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+}
+
// ─── SIGNATURES ────────────────────────────────────────────────────────────────
// GET /api/email/signatures — list signatures for a given agent
@@ -233,7 +240,15 @@ router.post('/send', async (req, res) => {
let processedBodyHtml = bodyHtml;
let cidCounter = 1;
processedBodyHtml = bodyHtml.replace(/src="data:([^;]+);base64,([^"]+)"/g, (match, contentType, base64Data) => {
- const cid = `inline-image-${Date.now()}-${cidCounter++}`;
+ let ext = 'png'; // default fallback
+ if (contentType) {
+ const parts = contentType.split('/');
+ if (parts.length === 2) {
+ ext = parts[1];
+ if (ext === 'jpeg') ext = 'jpg';
+ }
+ }
+ const cid = `inline-image-${Date.now()}-${cidCounter++}.${ext}`;
extractedInlineImages.push({
cid,
content: base64Data,
@@ -273,15 +288,17 @@ router.post('/send', async (req, res) => {
const toList = to.join(', ');
+ const localNow = getLocalTimestamp();
+
// 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, create_time, create_by, change_time, change_by
) VALUES (
- $1, 1, 1, 1, NOW(), $2, NOW(), $2
+ $1, 1, 1, 1, $3, $2, $3, $2
) RETURNING id`,
- [ticketId, agentId || 1]
+ [ticketId, agentId || 1, localNow]
);
const articleId = artInsert.rows[0]?.id;
@@ -290,8 +307,8 @@ router.post('/send', async (req, res) => {
// Write standard HTML MIME data
await pool.query(`
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]
+ VALUES ($1, $2, $3, $4, $5, $6, $7, 'text/html; charset=utf-8', $8, $9, $11, $10, $11, $10)`,
+ [articleId, aFrom, toList, cc.join(', '), bccList.join(', '), subject, processedBodyHtml, messageId, now, agentId || 1, localNow]
);
// Helper to strip HTML tags
@@ -314,8 +331,8 @@ router.post('/send', async (req, res) => {
// 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]
+ VALUES ($1, $2, $3, $4, $3, $4)`,
+ [articleId, plainBody, localNow, agentId || 1]
);
// 2. Populate OTRS fulltext search index (article_search_index)
@@ -372,7 +389,7 @@ router.post('/send', async (req, res) => {
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)`,
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $9, $8, $9, $8)`,
[
articleId,
att.filename,
@@ -381,7 +398,8 @@ router.post('/send', async (req, res) => {
att.contentId,
att.disposition,
att.content, // base64 text directly
- agentId || 1
+ agentId || 1,
+ localNow
]
);
} catch (attErr) {
diff --git a/routes/tickets.js b/routes/tickets.js
index 668bb9b..22d7143 100644
--- a/routes/tickets.js
+++ b/routes/tickets.js
@@ -3,6 +3,13 @@ const router = express.Router();
const pool = require('../db');
const { db, logAttivita } = require('../activityDb');
+// Helper to get local timestamp in YYYY-MM-DD HH:mm:ss format
+function getLocalTimestamp() {
+ const d = new Date();
+ const pad = (n) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+}
+
// Helper: resolve agent name from DB (best-effort, non-blocking)
async function resolveAgentName(agentId) {
try {
@@ -427,7 +434,7 @@ router.get('/:id', async (req, res) => {
// Fetch attachments metadata for all articles in the ticket
const attachmentsResult = await pool.query(
- `SELECT id, article_id, filename, content_size, content_type, disposition
+ `SELECT id, article_id, filename, content_size, content_type, disposition, content_id
FROM article_data_mime_attachment
WHERE article_id IN (
SELECT id FROM article WHERE ticket_id = $1
@@ -1238,6 +1245,8 @@ router.post('/:id/articles', async (req, res) => {
);
const channelId = channelResult.rows.length > 0 ? channelResult.rows[0].id : 1;
+ const localNow = getLocalTimestamp();
+
// Create article
const articleResult = await client.query(
`INSERT INTO article (
@@ -1245,9 +1254,9 @@ router.post('/:id/articles', async (req, res) => {
is_visible_for_customer, search_index_needs_rebuild,
create_time, create_by, change_time, change_by
) VALUES (
- $1, $2, $3, $4, 1, NOW(), $5, NOW(), $5
+ $1, $2, $3, $4, 1, $5, $6, $5, $6
) RETURNING id`,
- [id, senderTypeId, channelId, is_visible_for_customer ? 1 : 0, operatorId]
+ [id, senderTypeId, channelId, is_visible_for_customer ? 1 : 0, localNow, operatorId]
);
const articleId = articleResult.rows[0].id;
@@ -1269,9 +1278,9 @@ router.post('/:id/articles', async (req, res) => {
$1, $2, '', '', '', '', $3, $4,
'', '', '',
$5, EXTRACT(EPOCH FROM NOW())::INTEGER, $6,
- NOW(), $7, NOW(), $7
+ $7, $8, $7, $8
)`,
- [articleId, fromHeader, subject || 'Nota interna', body, contentType, contentPath, operatorId]
+ [articleId, fromHeader, subject || 'Nota interna', body, contentType, contentPath, localNow, operatorId]
);
// Create article_data_mime_attachment for OTRS CE HTML rendering (using file-1 and base64 encoded text)
@@ -1286,9 +1295,9 @@ router.post('/:id/articles', async (req, res) => {
create_time, create_by, change_time, change_by
) VALUES (
$1, 'file-1', $2, 'text/html; charset="utf-8"', '', $3,
- NOW(), $4, NOW(), $4
+ $4, $5, $4, $5
)`,
- [articleId, String(contentSize), base64Body, operatorId]
+ [articleId, String(contentSize), base64Body, localNow, operatorId]
);
// Insert additional attachments if any
@@ -1299,14 +1308,15 @@ router.post('/:id/articles', async (req, res) => {
`INSERT INTO article_data_mime_attachment (
article_id, filename, content_size, content_type, disposition, content,
create_time, create_by, change_time, change_by
- ) VALUES ($1, $2, $3, $4, 'attachment', $5, NOW(), $6, NOW(), $6)`,
+ ) VALUES ($1, $2, $3, $4, 'attachment', $5, $7, $6, $7, $6)`,
[
articleId,
att.filename,
contentBuffer.length,
att.content_type || 'application/octet-stream',
att.content, // base64 string directly
- operatorId
+ operatorId,
+ localNow
]
);
}
@@ -1320,16 +1330,15 @@ router.post('/:id/articles', async (req, res) => {
`INSERT INTO time_accounting (
ticket_id, article_id, time_unit,
create_time, create_by, change_time, change_by
- ) VALUES ($1, $2, $3, NOW(), $4, NOW(), $4)`,
- [id, articleId, parsedTime, operatorId]
+ ) VALUES ($1, $2, $3, $5, $4, $5, $4)`,
+ [id, articleId, parsedTime, operatorId, localNow]
);
}
}
- // Update ticket change_time
await client.query(
- `UPDATE ticket SET change_time = NOW(), change_by = $1 WHERE id = $2`,
- [operatorId, id]
+ `UPDATE ticket SET change_time = $1, change_by = $2 WHERE id = $3`,
+ [localNow, operatorId, id]
);
// Add history entry
@@ -1351,13 +1360,13 @@ router.post('/:id/articles', async (req, res) => {
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9,
- NOW(), $10, NOW(), $10
+ $11, $10, $11, $10
)`,
[
`%%`,
htResult.rows[0].id, id, articleId, t.type_id || 1, t.queue_id,
t.user_id, t.ticket_priority_id, t.ticket_state_id,
- operatorId
+ operatorId, localNow
]
);
}