Email ticketing fixes, comms polish, and .env cleanup
Inbound: - Gmail poll query is:unread in:inbox (was category:primary, which matched nothing on a no-tabs Workspace inbox) Outbound email: - Closed/escalation auto-emails editable via TICKET_CLOSE_MESSAGE and new TICKET_ESCALATION_EMAIL_MESSAGE; drop the staff signature from closing emails - Replies quote the customer's latest message (gmail_quote markup so clients collapse it), embed custom emoji inline via CID attachment, and strip Discord role mentions - Tagline spacing fix in the company signature Discord side: - Suppress all mentions in log + transcript posts (no more pinging on close) - Drop the staff-role ping from new-ticket and follow-up notifications - Ticket channels inherit category permissions instead of setting per-channel overwrites (removes the Manage Roles requirement) Gmail folders: - Folder/label routing (gmailLabels.js) with /folder; close files to Complete Config: - Remove ~56 stale .env keys for long-removed features; refresh stale copy Docs: - Design specs for folder routing, email-flow toggle, and per-staff metrics
This commit is contained in:
@@ -22,6 +22,8 @@ const { setNotifyDm } = require('../../services/staffSettings');
|
||||
const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
|
||||
const { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue');
|
||||
const { logError, logTicketEvent } = require('../../services/debugLog');
|
||||
const { applyConfigUpdates } = require('../../services/configPersistence');
|
||||
const { moveThreadToFolder, folderDisplayName } = require('../../services/gmailLabels');
|
||||
const { findTicketForChannel } = require('../sharedHelpers');
|
||||
|
||||
const { requireStaffRole, fetchLoggingChannel } = require('./helpers');
|
||||
@@ -152,7 +154,7 @@ async function handleTransfer(interaction) {
|
||||
if (logChan) {
|
||||
await enqueueSend(logChan, {
|
||||
content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}`,
|
||||
allowedMentions: { parse: ['users'] }
|
||||
allowedMentions: { parse: [] }
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -176,9 +178,10 @@ async function handleMove(interaction) {
|
||||
|
||||
const logChan = await fetchLoggingChannel(interaction.client);
|
||||
if (logChan) {
|
||||
await enqueueSend(logChan,
|
||||
`Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}`
|
||||
);
|
||||
await enqueueSend(logChan, {
|
||||
content: `Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}`,
|
||||
allowedMentions: { parse: [] }
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Move error:', err);
|
||||
@@ -245,6 +248,16 @@ async function handleGmailPoll(interaction) {
|
||||
// drop below 30s and trip Gmail's per-user quota under sustained load.
|
||||
const ms = Math.max(30000, requested * 1000);
|
||||
const seconds = ms / 1000;
|
||||
// While the inbound email flow is off, setting an interval must NOT silently
|
||||
// restart polling. Record it for this session (matches /gmailpoll's existing
|
||||
// runtime-only model) so it applies the next time someone runs /email on.
|
||||
if (!CONFIG.GMAIL_POLL_ENABLED) {
|
||||
CONFIG.GMAIL_POLL_INTERVAL_MS = ms;
|
||||
return interaction.reply({
|
||||
content: `Interval saved (${seconds}s), but the inbound email flow is currently **off** — it will apply when you run \`/email on\`.`,
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
// Lazy require — broccolini-discord re-exports this and we'd otherwise cycle.
|
||||
const { setGmailPollInterval } = require('../../broccolini-discord');
|
||||
setGmailPollInterval(ms);
|
||||
@@ -255,6 +268,82 @@ async function handleGmailPoll(interaction) {
|
||||
return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
async function handleEmail(interaction) {
|
||||
const sub = interaction.options.getSubcommand();
|
||||
|
||||
if (sub === 'status') {
|
||||
const intervalSec = Math.round(CONFIG.GMAIL_POLL_INTERVAL_MS / 1000);
|
||||
return interaction.reply({
|
||||
content: `Inbound email flow is **${CONFIG.GMAIL_POLL_ENABLED ? 'on' : 'off'}**.\nPoll interval: ${intervalSec}s.`,
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
const enable = sub === 'on';
|
||||
// applyConfigUpdates writes both CONFIG and .env so the state survives restart.
|
||||
const { applied, errors } = applyConfigUpdates({ GMAIL_POLL_ENABLED: enable });
|
||||
if (!applied.includes('GMAIL_POLL_ENABLED')) {
|
||||
const reason = (errors.find(e => e.key === 'GMAIL_POLL_ENABLED') || {}).error || 'unknown error';
|
||||
return interaction.reply({
|
||||
content: `Failed to turn email flow ${enable ? 'on' : 'off'}: ${reason}`,
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
// Lazy require — broccolini-discord re-exports these and we'd otherwise cycle.
|
||||
const { setGmailPollInterval, clearGmailPollInterval } = require('../../broccolini-discord');
|
||||
if (enable) {
|
||||
// Clear any auth-suspend latch so a prior invalid_grant doesn't keep polling
|
||||
// dead. If auth is still broken, the next cycle re-suspends and DMs admin.
|
||||
try { require('../../gmail-poll').setPollSuspended(false); } catch (_) {}
|
||||
setGmailPollInterval(CONFIG.GMAIL_POLL_INTERVAL_MS);
|
||||
} else {
|
||||
clearGmailPollInterval();
|
||||
}
|
||||
|
||||
logTicketEvent('Email flow toggled', [
|
||||
{ name: 'State', value: enable ? 'on' : 'off' },
|
||||
{ name: 'Set by', value: interaction.user.tag }
|
||||
], interaction).catch(() => {});
|
||||
|
||||
return interaction.reply({
|
||||
content: enable
|
||||
? 'Inbound email flow is now **on** — the inbox will be polled.'
|
||||
: 'Inbound email flow is now **off** — the inbox will not be polled. Outbound emails still send.',
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
async function handleFolder(interaction) {
|
||||
const folderKey = interaction.options.getString('destination');
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
// Discord-origin tickets have no Gmail thread to file.
|
||||
if (ticket.gmailThreadId.startsWith('discord-')) {
|
||||
return interaction.reply({
|
||||
content: "This ticket has no email thread, so it can't be moved to a Gmail folder.",
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
const label = folderDisplayName(folderKey) || 'Spam';
|
||||
// Defer: resolving/creating labels + threads.modify can exceed the 3s window.
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
await moveThreadToFolder(ticket.gmailThreadId, folderKey);
|
||||
logTicketEvent('Email thread filed', [
|
||||
{ name: 'Folder', value: label },
|
||||
{ name: 'Filed by', value: interaction.user.tag }
|
||||
], interaction).catch(() => {});
|
||||
return interaction.editReply({ content: `Moved this ticket's email thread to **${label}**.` });
|
||||
} catch (err) {
|
||||
logError('handleFolder', err, interaction).catch(() => {});
|
||||
return interaction.editReply({ content: `Failed to move the email thread: ${err.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleHelp(interaction) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Ticket System - Commands')
|
||||
@@ -266,7 +355,7 @@ async function handleHelp(interaction) {
|
||||
},
|
||||
{
|
||||
name: 'Ticket Management',
|
||||
value: '`/transfer @staff [reason]` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/cancel-close` - Abort a pending force-close countdown\n`/topic <text>` - Set ticket topic/description'
|
||||
value: '`/transfer @staff [reason]` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/cancel-close` - Abort a pending force-close countdown\n`/topic <text>` - Set ticket topic/description\n`/folder <destination>` - File this ticket\'s email into a Gmail folder'
|
||||
},
|
||||
{
|
||||
name: 'Saved Responses',
|
||||
@@ -286,7 +375,7 @@ async function handleHelp(interaction) {
|
||||
},
|
||||
{
|
||||
name: 'Staff Configuration',
|
||||
value: '`/notifydm` - Toggle DM notifications for your claimed tickets\n`/signature` - Set your email signature\n`/closetimer <seconds>` - Set the force-close countdown\n`/staffthread` - Toggle/configure per-ticket staff threads\n`/pinmessages` - Toggle auto-pinning of ticket messages\n`/gmailpoll <interval>` - Set the Gmail poll interval'
|
||||
value: '`/notifydm` - Toggle DM notifications for your claimed tickets\n`/signature` - Set your email signature\n`/closetimer <seconds>` - Set the force-close countdown\n`/staffthread` - Toggle/configure per-ticket staff threads\n`/pinmessages` - Toggle auto-pinning of ticket messages\n`/gmailpoll <interval>` - Set the Gmail poll interval\n`/email on|off|status` - Turn the inbound email flow on/off'
|
||||
},
|
||||
{
|
||||
name: 'Right-click (Apps menu)',
|
||||
@@ -313,6 +402,8 @@ const COMMAND_HANDLERS = {
|
||||
staffthread: handleStaffThread,
|
||||
pinmessages: handlePinMessages,
|
||||
gmailpoll: handleGmailPoll,
|
||||
email: handleEmail,
|
||||
folder: handleFolder,
|
||||
closetimer: handleCloseTimer,
|
||||
'cancel-close': handleCancelClose,
|
||||
'force-close': handleForceClose,
|
||||
|
||||
Reference in New Issue
Block a user