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:
2026-06-04 22:05:20 +00:00
parent 3e20f9cf86
commit 2ccdbf72aa
19 changed files with 1224 additions and 83 deletions

View File

@@ -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,