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:
Gabriele Cimaschi
2026-07-09 22:36:46 +02:00
parent 725bdaeae4
commit 191f9a407b
11 changed files with 833 additions and 313 deletions
+242 -53
View File
@@ -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(', ')}`);
+1 -1
View File
@@ -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