From d1e1408256983ad6a436fe767d465a862c616a64 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Tue, 21 Apr 2026 20:54:49 +0000 Subject: [PATCH] email fixes --- handlers/buttons.js | 58 +++++++++++++++++------- services/gmail.js | 105 ++++++++++++++++++++++++-------------------- services/tickets.js | 2 +- 3 files changed, 101 insertions(+), 64 deletions(-) diff --git a/handlers/buttons.js b/handlers/buttons.js index a61eccc..cc0fa44 100644 --- a/handlers/buttons.js +++ b/handlers/buttons.js @@ -93,16 +93,35 @@ async function handleButton(interaction) { // --- CLOSE --- if (interaction.customId === 'close_ticket') { - const confirmRow = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('confirm_close') - .setLabel('Confirm Close') - .setStyle(ButtonStyle.Danger), - new ButtonBuilder() - .setCustomId('cancel_close') - .setLabel('Cancel') - .setStyle(ButtonStyle.Secondary) - ); + const isEmailTicket = ticket.gmailThreadId && !ticket.gmailThreadId.startsWith('discord-'); + const confirmRow = new ActionRowBuilder(); + if (isEmailTicket) { + confirmRow.addComponents( + new ButtonBuilder() + .setCustomId('confirm_close_with_email') + .setLabel('Confirm Close With Email') + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId('confirm_close_no_email') + .setLabel('Confirm Close Without Email') + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId('cancel_close') + .setLabel('Cancel') + .setStyle(ButtonStyle.Secondary) + ); + } else { + confirmRow.addComponents( + new ButtonBuilder() + .setCustomId('confirm_close') + .setLabel('Confirm Close') + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId('cancel_close') + .setLabel('Cancel') + .setStyle(ButtonStyle.Secondary) + ); + } return interaction.reply({ content: 'Are you sure you want to close this ticket?', @@ -110,7 +129,12 @@ async function handleButton(interaction) { }); } - if (interaction.customId === 'confirm_close') { + if ( + interaction.customId === 'confirm_close' || + interaction.customId === 'confirm_close_with_email' || + interaction.customId === 'confirm_close_no_email' + ) { + const sendEmail = interaction.customId !== 'confirm_close_no_email'; const timerSeconds = CONFIG.FORCE_CLOSE_TIMER; if (pendingCloses.has(interaction.channel.id)) { return interaction.reply({ content: 'A close is already pending for this ticket.', ephemeral: true }); @@ -123,6 +147,7 @@ async function handleButton(interaction) { ); await interaction.update({ content: `Closing ticket in ${timerSeconds} seconds.`, components: [cancelRow] }); const timerId = setTimeout(async () => { + const pending = pendingCloses.get(interaction.channel.id); pendingCloses.delete(interaction.channel.id); const freshTicket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean(); if (!freshTicket || freshTicket.status === 'closed') return; @@ -132,9 +157,10 @@ async function handleButton(interaction) { { name: 'Set by', value: interaction.user.tag }, { name: 'Duration', value: `${timerSeconds}s` } ]).catch(() => {}); - await handleConfirmClose(interaction, freshTicket); + const effectiveSendEmail = pending?.sendEmail ?? true; + await handleConfirmClose(interaction, freshTicket, effectiveSendEmail); }, timerSeconds * 1000); - pendingCloses.set(interaction.channel.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag }); + pendingCloses.set(interaction.channel.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag, sendEmail }); return; } @@ -394,7 +420,7 @@ async function handleClaim(interaction, ticket) { } // --- CONFIRM CLOSE --- -async function handleConfirmClose(interaction, ticket) { +async function handleConfirmClose(interaction, ticket, sendEmail = true) { const closedAt = new Date(); try { await interaction.update({ content: 'Archiving and closing...', components: [] }); @@ -522,8 +548,8 @@ async function handleConfirmClose(interaction, ticket) { const closerDisplayName = interaction.member?.displayName || interaction.user.username; - if (!ticket.gmailThreadId?.startsWith('discord-')) { - await sendTicketClosedEmail(ticket, closerDisplayName); + if (sendEmail && !ticket.gmailThreadId?.startsWith('discord-')) { + await sendTicketClosedEmail(ticket, closerDisplayName, interaction.user.id); } await Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, diff --git a/services/gmail.js b/services/gmail.js index 0385528..091ead1 100644 --- a/services/gmail.js +++ b/services/gmail.js @@ -74,7 +74,7 @@ async function reloadGmailClient() { return { emailAddress: profile.data.emailAddress }; } -async function sendTicketClosedEmail(ticket, discordDisplayName) { +async function sendTicketClosedEmail(ticket, closerName, userId = null) { try { const gmail = getGmailClient(); @@ -86,7 +86,7 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) { return; } - let subjectHeader = ticket.subject || 'Support'; + let originalSubject = null; let msgId = null; try { const thread = await gmail.users.threads.get({ @@ -94,63 +94,74 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) { id: ticket.gmailThreadId }); const messages = thread.data.messages || []; - const lastMsg = [...messages].reverse()[0]; - if (lastMsg?.payload?.headers) { - const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value; - if (subj) subjectHeader = subj; - msgId = sanitizeHeaderValue(lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value); + const firstMsg = messages[0]; + if (firstMsg?.payload?.headers) { + const subj = firstMsg.payload.headers.find(h => h.name === 'Subject')?.value; + if (subj) originalSubject = subj; + msgId = sanitizeHeaderValue(firstMsg.payload.headers.find(h => h.name === 'Message-ID')?.value); } - } catch (_) { - /* use ticket.subject and no In-Reply-To if thread fetch fails */ + } catch (_) {} + + const baseSubject = originalSubject || ticket.subject || 'Support'; + const stripped = String(baseSubject).replace(/^(?:\s*Re\s*:\s*)+/i, ''); + const safeSubject = sanitizeHeaderValue(`Re: ${stripped}`); + const utf8Subject = `=?utf-8?B?${Buffer.from(safeSubject).toString('base64')}?=`; + + const messageBody = `${closerName} has marked this ticket as resolved. If you would like to re-open this issue, please reply to this email.`; + + let signatureBlocks = { text: '', html: '' }; + if (userId) { + signatureBlocks = await getStaffSignatureBlocks(userId); } + // signatureBlocks.html arrives pre-escaped from getStaffSignatureBlocks. + const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '
') : ''; + const safeStaffSigText = signatureBlocks.text; - const finalSubject = sanitizeHeaderValue(`${CONFIG.TICKET_CLOSE_SUBJECT_PREFIX} ${subjectHeader}`); - const utf8Subject = `=?utf-8?B?${Buffer.from( - finalSubject - ).toString('base64')}?=`; - - const serverDisplayName = escapeHtml(discordDisplayName || CONFIG.SUPPORT_NAME || 'Support'); - const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || ''); - const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '
'); - const safeCloseMessage = escapeHtml(CONFIG.TICKET_CLOSE_MESSAGE || '').replace(/\n/g, '
'); - const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || '').replace(/\n/g, '
'); const htmlBody = ` -
-

From: ${serverDisplayName} on Discord

-

Message:

-

${safeCloseMessage}

-

${safeCloseSignature}

-
- - - - - -
- ${safeLogoUrl ? `` : ''} - -

${serverDisplayName}

-
${safeSignature}
-
-
`; +
+

${escapeHtml(messageBody).replace(/\n/g, '
')}

+ ${safeStaffSigHtml ? `

${safeStaffSigHtml}

` : ''} + ${buildCompanySigHtml()} +
`; - const rawHeaders = [ + const boundary = '000000000000' + Date.now().toString(16); + + const plainBody = []; + plainBody.push(messageBody); + if (safeStaffSigText) { + plainBody.push(''); + plainBody.push(safeStaffSigText); + } + plainBody.push(''); + plainBody.push(...buildCompanySigText().split('\n')); + + const headers = [ `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`, `To: ${recipientEmail}`, `Subject: ${utf8Subject}`, - msgId ? `In-Reply-To: ${msgId}` : '', - msgId ? `References: ${msgId}` : '', + msgId && `In-Reply-To: ${msgId}`, + msgId && `References: ${msgId}`, 'MIME-Version: 1.0', - 'Content-Type: text/html; charset="UTF-8"', - '', - htmlBody + `Content-Type: multipart/alternative; boundary="${boundary}"` ].filter(Boolean); - const raw = Buffer.from(rawHeaders.join('\r\n')) + const raw = Buffer.from([ + ...headers, + '', + `--${boundary}`, + 'Content-Type: text/plain; charset="UTF-8"', + '', + ...plainBody, + '', + `--${boundary}`, + 'Content-Type: text/html; charset="UTF-8"', + '', + htmlBody, + '', + `--${boundary}--` + ].join('\r\n')) .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); await gmail.users.messages.send({ userId: 'me', diff --git a/services/tickets.js b/services/tickets.js index 6cf31e3..b9b1b7a 100644 --- a/services/tickets.js +++ b/services/tickets.js @@ -385,7 +385,7 @@ async function checkAutoClose(client, sendTicketClosedEmail) { { $set: { status: 'closed', pendingDelete: true } } )); - await sendTicketClosedEmail(ticket, 'Auto-Close System'); + await sendTicketClosedEmail(ticket, 'Auto-Close System', null); setTimeout(() => { enqueueDelete(channel).then(() => {