diff --git a/.env.example b/.env.example
index 8147bcb..fc7622a 100644
--- a/.env.example
+++ b/.env.example
@@ -14,10 +14,8 @@ DISCORD_GUILD_ID= # Server (guild) ID where the bot runs
DISCORD_TICKET_CATEGORY_ID= # Category for Discord-originated ticket channels
TICKET_CATEGORY_ID= # Category for email-originated ticket channels
-# Category display names (primary must match the category name in Discord; overflow folders are created as "{name} (Overflow 1)", etc.)
+# Category display name (primary must match the category name in Discord; overflow folders are created as "{name} (Overflow 1)", etc.)
TICKET_CATEGORY_NAME=Open Tickets
-TICKET_T2_CATEGORY_NAME=Tier 2 Escalated Tickets
-TICKET_T3_CATEGORY_NAME=Tier 3 Escalated Tickets
# Escalation categories (tier 2 and tier 3)
DISCORD_ESCALATED_CATEGORY_ID= # Fallback escalation category (Discord)
@@ -32,7 +30,6 @@ ROLE_ID_TO_PING= # Role ID to ping on new tickets (config also
TRANSCRIPT_CHANNEL_ID= # Channel for ticket transcripts on close
LOGGING_CHANNEL_ID= # Channel for lifecycle log messages
DEBUGGING_CHANNEL_ID= # Channel for error logs (escalate, poll, etc.); optional
-DISCORD_CHANNEL_ID= # General Discord channel (if used)
# --- Discord: Ticket copy & buttons ---
# ESCALATION_MESSAGE: use {support_name} for SUPPORT_NAME
@@ -50,8 +47,7 @@ GOOGLE_CLIENT_SECRET= # OAuth2 Client Secret
REFRESH_TOKEN= # OAuth2 refresh token for the support inbox
MY_EMAIL= # Support inbox email address
-# --- Server & URLs ---
-NGROK_URL= # Public URL (optional); run ngrok outside this repo
+# --- Server ---
DISCORD_ONLY_PORT=5000 # Port for healthcheck / Discord-only server
# HEALTHCHECK_HOST= # Optional; bind address for health server (default: all interfaces; use 127.0.0.1 for local-only)
@@ -74,7 +70,6 @@ DISCORD_AUTO_CLOSE_MESSAGE= # Message in ticket when auto-closed (e.g. ...
# --- Ticket limits & permissions ---
GLOBAL_TICKET_LIMIT=5 # Max concurrent open tickets globally
-TICKET_LIMIT_PER_CATEGORY=3 # Max tickets per category
RATE_LIMIT_TICKETS_PER_USER=0 # Max tickets per user per window (0 = disabled)
RATE_LIMIT_WINDOW_MINUTES=60 # Window in minutes for per-user rate limit
BLACKLISTED_ROLES= # Comma-separated role IDs that cannot open tickets
@@ -83,7 +78,6 @@ ADDITIONAL_STAFF_ROLES= # Comma-separated role IDs with staff permissi
# --- Auto-close ---
AUTO_CLOSE_ENABLED=false
AUTO_CLOSE_AFTER_HOURS=72
-AUTO_CLOSE_MESSAGE= # Message when ticket is auto-closed
# --- Reminders ---
REMINDER_ENABLED=false
@@ -140,7 +134,6 @@ GAME_LIST=Project Zomboid, Minecraft, ...
# --- Embed colors (hex with 0x prefix) ---
EMBED_COLOR_OPEN=0x00FF00
-EMBED_COLOR_CLOSED=0xFF0000
EMBED_COLOR_CLAIMED=0xFFFF00
EMBED_COLOR_ESCALATED=0xFF6600
EMBED_COLOR_INFO=0x1e2124
diff --git a/api/botClient.js b/api/botClient.js
deleted file mode 100644
index 0fcbabf..0000000
--- a/api/botClient.js
+++ /dev/null
@@ -1,15 +0,0 @@
-/**
- * bOSScord API: reference to the Discord bot client.
- * Set in broccolini-discord.js when client fires "ready"; read by bosscord routes.
- */
-let botClient = null;
-
-function setBot(client) {
- botClient = client;
-}
-
-function getBot() {
- return botClient;
-}
-
-module.exports = { setBot, getBot };
diff --git a/broccolini-discord.js b/broccolini-discord.js
index 626be6c..a6773d8 100644
--- a/broccolini-discord.js
+++ b/broccolini-discord.js
@@ -17,15 +17,8 @@ const { handleDiscordReply } = require('./handlers/messages');
const { sendTicketClosedEmail } = require('./services/gmail');
const { checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels, resumePendingDeletes } = require('./services/tickets');
const { registerCommands } = require('./commands/register');
-// Holds a reference to the Discord client for the settings-site /internal/discord/guild lookup.
-const { setBot } = require('./api/botClient');
const { poll } = require('./gmail-poll');
-const { setClient: setDebugClient, logError, logSystem } = require('./services/debugLog');
-
-// Re-export utilities for any external consumers
-const { sendGmailReply } = require('./services/gmail');
-const { getNextTicketNumber } = require('./services/tickets');
-const { getCleanBody, detectGame, stripEmailQuotes, stripMobileFooter, htmlToTextWithBlocks } = require('./utils');
+const { setClient: setDebugClient, logError } = require('./services/debugLog');
let gmailPollInterval = null;
// Track all background setInterval handles so shutdown can clear them.
@@ -179,7 +172,6 @@ client.once('ready', async () => {
}
await connectMongoDB(process.env.MONGODB_URI);
setDebugClient(client);
- setBot(client);
const healthcheckHost = CONFIG.HEALTHCHECK_HOST || undefined;
httpServer = app.listen(CONFIG.PORT, healthcheckHost, () => {
console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`);
@@ -229,18 +221,6 @@ client.once('ready', async () => {
console.log('✓ Memory sweeps registered: every 6 hours (unref\'d)');
console.log('✓ Discord bot ready. Tag:', client.user.tag);
-
- logSystem('Bot online', [
- { name: 'Guild', value: guild ? `${guild.name} (${guild.id})` : 'N/A' },
- { name: 'Poll interval', value: `${CONFIG.GMAIL_POLL_INTERVAL_MS / 1000}s` },
- { name: 'Auto-close', value: CONFIG.AUTO_CLOSE_ENABLED ? `enabled (${CONFIG.AUTO_CLOSE_AFTER_HOURS}h)` : 'disabled' },
- { name: 'Auto-unclaim', value: CONFIG.AUTO_UNCLAIM_ENABLED ? `enabled (${CONFIG.AUTO_UNCLAIM_AFTER_HOURS}h)` : 'disabled' },
- { name: 'Gmail log', value: CONFIG.GMAIL_LOG_CHANNEL_ID ? 'configured' : 'not configured' },
- { name: 'Automation log', value: CONFIG.AUTOMATION_LOG_CHANNEL_ID ? 'configured' : 'not configured' },
- { name: 'Staff threads', value: CONFIG.STAFF_THREAD_ENABLED ? `enabled (name: "${CONFIG.STAFF_THREAD_NAME}")` : 'disabled' },
- { name: 'Pin initial message', value: CONFIG.PIN_INITIAL_MESSAGE_ENABLED ? 'enabled' : 'disabled' },
- { name: 'Pin escalation message', value: CONFIG.PIN_ESCALATION_MESSAGE_ENABLED ? 'enabled' : 'disabled' }
- ]).catch(() => {});
});
client.login(CONFIG.DISCORD_TOKEN);
@@ -284,10 +264,7 @@ let shuttingDown = false;
async function handleShutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
- await Promise.race([
- logSystem('Bot shutting down', [{ name: 'Signal', value: signal }]),
- new Promise(r => setTimeout(r, 2000))
- ]);
+ console.log(`Bot shutting down (${signal})`);
for (const handle of activeIntervals) {
try { clearInterval(handle); } catch (_) {}
}
@@ -313,13 +290,5 @@ module.exports = {
client,
setGmailPollInterval,
clearGmailPollInterval,
- trackTimeout,
- sendGmailReply,
- sendTicketClosedEmail,
- getNextTicketNumber,
- getCleanBody,
- detectGame,
- stripEmailQuotes,
- stripMobileFooter,
- htmlToTextWithBlocks
+ trackTimeout
};
diff --git a/config.js b/config.js
index 0c4467a..6608657 100644
--- a/config.js
+++ b/config.js
@@ -1,25 +1,9 @@
/**
* Broccolini Bot configuration and game lists.
- * Load dotenv so env is available when this module is required first.
- * dotenv-expand resolves ${NGROK_URL} etc. in .env.
*
* Never commit .env; agents must not modify .env without explicit user confirmation.
*/
-const path = require('path');
-const dotenv = require('dotenv');
-const dotenvExpand = require('dotenv-expand');
-
-const parsed = dotenv.config({ debug: process.env.NODE_ENV === 'development' });
-dotenvExpand.expand(parsed);
-// Also load repo-root .env; only non-empty values override (so empty DISCORD_BOT_TOKEN= in root does not wipe app .env)
-const rootEnv = path.resolve(process.cwd(), '..', '.env');
-const rootParsed = dotenv.config({ path: rootEnv });
-if (!rootParsed.error && rootParsed.parsed) {
- for (const [k, v] of Object.entries(rootParsed.parsed)) {
- if (v != null && String(v).trim() !== '') process.env[k] = v;
- }
- dotenvExpand.expand(rootParsed);
-}
+require('dotenv').config({ debug: process.env.NODE_ENV === 'development' });
function toInt(v, fallback) {
const n = parseInt(v, 10);
@@ -31,23 +15,12 @@ const CONFIG = {
DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID || null,
TICKET_CATEGORY_ID: process.env.TICKET_CATEGORY_ID,
TICKET_CATEGORY_NAME: process.env.TICKET_CATEGORY_NAME || 'Open Tickets',
- TICKET_T2_CATEGORY_NAME: process.env.TICKET_T2_CATEGORY_NAME || 'Tier 2 Escalated Tickets',
- TICKET_T3_CATEGORY_NAME: process.env.TICKET_T3_CATEGORY_NAME || 'Tier 3 Escalated Tickets',
- EMAIL_TICKET_OVERFLOW_CATEGORY_IDS: (process.env.EMAIL_TICKET_OVERFLOW_CATEGORY_IDS || '')
- .split(',')
- .map(s => s.trim())
- .filter(Boolean),
DISCORD_TICKET_CATEGORY_ID: process.env.DISCORD_TICKET_CATEGORY_ID || process.env.TICKET_CATEGORY_ID,
- DISCORD_TICKET_OVERFLOW_CATEGORY_IDS: (process.env.DISCORD_TICKET_OVERFLOW_CATEGORY_IDS || '')
- .split(',')
- .map(s => s.trim())
- .filter(Boolean),
ROLE_ID_TO_PING: process.env.ROLE_ID_TO_PING,
ROLE_TO_PING_ID: process.env.ROLE_ID_TO_PING || process.env.ROLE_TO_PING_ID,
TRANSCRIPT_CHAN: process.env.TRANSCRIPT_CHANNEL_ID,
LOG_CHAN: process.env.LOGGING_CHANNEL_ID,
DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null,
- DISCORD_CHANNEL_ID: process.env.DISCORD_CHANNEL_ID || null,
CLIENT_ID: process.env.DISCORD_APPLICATION_ID,
REFRESH_TOKEN: process.env.REFRESH_TOKEN,
MY_EMAIL: (process.env.MY_EMAIL || '').toLowerCase(),
@@ -75,9 +48,7 @@ const CONFIG = {
DISCORD_AUTO_CLOSE_MESSAGE: process.env.DISCORD_AUTO_CLOSE_MESSAGE || 'This ticket was closed due to inactivity. If you still need assistance, please open a new ticket.',
AUTO_CLOSE_ENABLED: process.env.AUTO_CLOSE_ENABLED === 'true',
AUTO_CLOSE_AFTER_HOURS: toInt(process.env.AUTO_CLOSE_AFTER_HOURS, 72),
- AUTO_CLOSE_MESSAGE: process.env.AUTO_CLOSE_MESSAGE || 'This ticket has been automatically closed due to inactivity.',
GLOBAL_TICKET_LIMIT: toInt(process.env.GLOBAL_TICKET_LIMIT, 5),
- TICKET_LIMIT_PER_CATEGORY: toInt(process.env.TICKET_LIMIT_PER_CATEGORY, 3),
RATE_LIMIT_TICKETS_PER_USER: toInt(process.env.RATE_LIMIT_TICKETS_PER_USER, 0),
RATE_LIMIT_WINDOW_MINUTES: toInt(process.env.RATE_LIMIT_WINDOW_MINUTES, 60),
BLACKLISTED_ROLES: (process.env.BLACKLISTED_ROLES || '').split(',').map(r => r.trim()).filter(Boolean),
@@ -103,7 +74,6 @@ const CONFIG = {
BUTTON_EMOJI_CLAIM: process.env.BUTTON_EMOJI_CLAIM || '📌',
BUTTON_EMOJI_UNCLAIM: process.env.BUTTON_EMOJI_UNCLAIM || '🔓',
EMBED_COLOR_OPEN: toInt(process.env.EMBED_COLOR_OPEN, 0x00FF00),
- EMBED_COLOR_CLOSED: toInt(process.env.EMBED_COLOR_CLOSED, 0xFF0000),
EMBED_COLOR_CLAIMED: toInt(process.env.EMBED_COLOR_CLAIMED, 0xFFFF00),
EMBED_COLOR_ESCALATED: toInt(process.env.EMBED_COLOR_ESCALATED, 0xFF6600),
EMBED_COLOR_INFO: toInt(process.env.EMBED_COLOR_INFO, 0x1e2124),
@@ -142,42 +112,8 @@ const GAME_ALIASES = {
CS2: 'Counter-Strike 2'
};
-const GAME_NAME_TO_KEY = {
- 'Project Zomboid': 'project_zomboid',
- 'Satisfactory': 'satisfactory',
- 'Palworld': 'palworld',
- 'Minecraft': 'minecraft',
- 'Valheim': 'valheim',
- 'Enshrouded': 'enshrouded',
- '7 Days to Die': '7_days_to_die',
- 'Hytale': 'hytale',
- 'ICARUS': 'icarus',
- 'Abiotic Factor': 'abiotic_factor',
- 'ARK: Survival Evolved': 'ark_survival_evolved',
- 'Conan Exiles': 'conan_exiles',
- 'Core Keeper': 'core_keeper',
- 'Counter-Strike 2': 'counter_strike_2',
- 'DayZ': 'dayz',
- 'ECO': 'eco',
- 'Factorio': 'factorio',
- 'FiveM': 'fivem',
- 'The Front': 'the_front',
- "Garry's Mod": 'garrys_mod',
- 'Necesse': 'necesse',
- 'Rust': 'rust',
- 'Sons of the Forest': 'sons_of_the_forest',
- 'Soulmask': 'soulmask',
- 'Star Rupture': 'star_rupture',
- 'Terraria': 'terraria',
- 'VEIN': 'vein',
- 'Vintage Story': 'vintage_story',
- 'Voyagers of Nera': 'voyagers_of_nera',
- 'V Rising': 'v_rising'
-};
-
module.exports = {
CONFIG,
GAME_NAMES,
- GAME_ALIASES,
- GAME_NAME_TO_KEY
+ GAME_ALIASES
};
diff --git a/db-connection.js b/db-connection.js
index 4d4ab67..bf34a19 100644
--- a/db-connection.js
+++ b/db-connection.js
@@ -22,23 +22,16 @@ async function connectMongoDB(uri, options = {}) {
await mongoose.connect(uri, defaultOptions);
console.log('✓ Connected to MongoDB');
- // Handle connection events
mongoose.connection.on('error', (err) => {
console.error('MongoDB connection error:', err);
- const { logSystem: ls } = require('./services/debugLog');
- ls('MongoDB error', [{ name: 'Error', value: err.message }], null, 0xFF0000).catch(() => {});
});
mongoose.connection.on('disconnected', () => {
console.warn('MongoDB disconnected. Attempting to reconnect...');
- const { logSystem: ls } = require('./services/debugLog');
- ls('MongoDB disconnected', [], null, 0xFFFF00).catch(() => {});
});
mongoose.connection.on('reconnected', () => {
console.log('✓ MongoDB reconnected');
- const { logSystem: ls } = require('./services/debugLog');
- ls('MongoDB reconnected', []).catch(() => {});
});
} catch (err) {
diff --git a/gmail-poll.js b/gmail-poll.js
index 0cbeb09..77f11a6 100644
--- a/gmail-poll.js
+++ b/gmail-poll.js
@@ -7,7 +7,7 @@ const {
EmbedBuilder
} = require('discord.js');
const { mongoose, withRetry } = require('./db-connection');
-const { CONFIG, GAME_NAME_TO_KEY } = require('./config');
+const { CONFIG } = require('./config');
const {
getCleanBody,
extractRawEmail,
@@ -19,7 +19,7 @@ const {
} = require('./utils');
const { getGmailClient } = require('./services/gmail');
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
-const { logError, logGmail, logAutomation } = require('./services/debugLog');
+const { logError } = require('./services/debugLog');
const { enqueueSend } = require('./services/channelQueue');
const { getTicketActionRow } = require('./utils/ticketComponents');
@@ -29,7 +29,6 @@ const Transcript = mongoose.model('Transcript');
let isPolling = false;
let authErrorNotified = false;
let pollSuspended = false;
-let pollCount = 0, totalProcessed = 0, totalSkipped = 0, totalErrors = 0;
function setPollSuspended(val) {
pollSuspended = !!val;
@@ -45,13 +44,6 @@ async function poll(client) {
if (isPolling || pollSuspended) return;
isPolling = true;
try {
- pollCount++;
- if (pollCount % 10 === 0) {
- if (totalProcessed > 0 || totalSkipped > 0 || totalErrors > 0) {
- logAutomation('Gmail poll summary', null, `polls: ${pollCount}, processed: ${totalProcessed}, skipped: ${totalSkipped}, errors: ${totalErrors}`).catch(() => {});
- }
- pollCount = 0; totalProcessed = 0; totalSkipped = 0; totalErrors = 0;
- }
console.log('Running poll()...');
try {
const gmail = getGmailClient();
@@ -89,7 +81,6 @@ async function poll(client) {
email.data.payload.headers.find(h => h.name === 'From')
?.value || '';
if (from.toLowerCase().includes(CONFIG.MY_EMAIL)) {
- totalSkipped++;
await gmail.users.messages.batchModify({
userId: 'me',
requestBody: {
@@ -173,7 +164,6 @@ async function poll(client) {
// Check ticket limits before creating
const limitCheck = await checkTicketLimits(sEmail);
if (!limitCheck.ok) {
- totalSkipped++;
console.log(`Ticket limit reached for ${sEmail}: ${limitCheck.reason}`);
await gmail.users.messages.batchModify({
userId: 'me',
@@ -224,11 +214,6 @@ async function poll(client) {
const detectedGame = detectGame(subject, rawBody);
- const gameKey =
- detectedGame && detectedGame !== 'Not Mentioned'
- ? GAME_NAME_TO_KEY[detectedGame] || null
- : null;
-
const buttons = getTicketActionRow({ escalationTier: 0 });
const ticketInfoEmbed = new EmbedBuilder()
@@ -326,8 +311,6 @@ async function poll(client) {
},
{ upsert: true, new: true }
));
- totalProcessed++;
- logGmail(subject, sEmail, number, detectedGame).catch(() => {});
}
console.log('Archiving/reading Gmail message', msgRef.id);
await gmail.users.messages.batchModify({
@@ -359,7 +342,6 @@ async function poll(client) {
}
}
- totalErrors++;
console.error('POLL ERROR:', e);
logError('Gmail poll', e, null, client).catch(() => {});
}
diff --git a/handlers/buttons.js b/handlers/buttons.js
index cc0fa44..3b3ab78 100644
--- a/handlers/buttons.js
+++ b/handlers/buttons.js
@@ -16,14 +16,14 @@ const {
} = require('discord.js');
const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config');
-const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, getSenderLocal, toDiscordSafeName } = require('../services/tickets');
+const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName } = require('../services/tickets');
const { sendTicketClosedEmail } = require('../services/gmail');
const { getTicketActionRow } = require('../utils/ticketComponents');
-const { sanitizeEmbedText, truncateEmbedDescription, truncateEmbedField, enforceEmbedLimit } = require('../utils');
+const { sanitizeEmbedText, truncateEmbedDescription, enforceEmbedLimit } = require('../utils');
const { enqueueRename, enqueueSend } = require('../services/channelQueue');
const { runEscalation, runDeescalation } = require('./commands');
const { pendingCloses } = require('./pendingCloses');
-const { logError, logSystem } = require('../services/debugLog');
+const { logError } = require('../services/debugLog');
const Ticket = mongoose.model('Ticket');
const Transcript = mongoose.model('Transcript');
@@ -328,8 +328,6 @@ async function handleClaim(interaction, ticket) {
}
if (isClaimed && !isClaimedByMe && !CONFIG.ALLOW_CLAIM_OVERWRITE) {
- const { logSecurity } = require('../services/debugLog');
- logSecurity('Unauthorized button attempt', interaction.user, interaction.customId).catch(() => {});
return interaction.reply({
content: `This ticket is already claimed by **${freshTicket.claimedBy}**.`,
ephemeral: true
@@ -510,13 +508,8 @@ async function handleConfirmClose(interaction, ticket, sendEmail = true) {
files: [dmFile]
});
} catch (dmErr) {
- // 50007 = "Cannot send messages to this user" — user has DMs off. Expected class; debug-level only.
- if (dmErr?.code === 50007) {
- logSystem('Transcript DM skipped (recipient has DMs disabled)', [
- { name: 'User', value: creatorId },
- { name: 'Channel', value: channelName }
- ]).catch(() => {});
- } else {
+ // 50007 = "Cannot send messages to this user" — user has DMs off. Expected; ignore.
+ if (dmErr?.code !== 50007) {
logError('transcript-dm', dmErr).catch(() => {});
}
}
@@ -662,8 +655,6 @@ async function handleTicketModal(interaction) {
parentCategoryId: parentCategoryIdForTicket
});
- const displayName = interaction.member?.displayName || interaction.user.username;
-
const descTrimmed = description.length > 500 ? description.slice(0, 497) + '…' : description;
const welcomeEmbed = new EmbedBuilder()
diff --git a/handlers/commands.js b/handlers/commands.js
index f4c7425..2c73d99 100644
--- a/handlers/commands.js
+++ b/handlers/commands.js
@@ -3,7 +3,6 @@
*/
const {
ChannelType,
- ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
AttachmentBuilder,
@@ -18,7 +17,7 @@ const { sendTicketNotificationEmail } = require('../services/gmail');
const { getTicketActionRow } = require('../utils/ticketComponents');
const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue');
const { setNotifyDm } = require('../services/staffSettings');
-const { logTicketEvent, logSecurity, logError } = require('../services/debugLog');
+const { logError } = require('../services/debugLog');
const { pendingCloses } = require('./pendingCloses');
const Ticket = mongoose.model('Ticket');
@@ -51,7 +50,6 @@ async function requireStaffRole(interaction) {
content: `This command is only available to the support team (${roleMention}).`,
ephemeral: true
});
- logSecurity('Unauthorized command attempt', interaction.user, interaction.commandName).catch(() => {});
return true;
}
@@ -130,7 +128,6 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
ticket,
null,
emailBody,
- escalatorName,
interaction.user.id
);
} catch (emailErr) {
diff --git a/handlers/messages.js b/handlers/messages.js
index b2e4b59..92cb3a1 100644
--- a/handlers/messages.js
+++ b/handlers/messages.js
@@ -3,7 +3,7 @@
*/
const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config');
-const { extractRawEmail } = require('../utils');
+const { extractRawEmail, isStaff } = require('../utils');
const { getGmailClient, sendGmailReply } = require('../services/gmail');
const { updateTicketActivity } = require('../services/tickets');
const { getNotifyDm } = require('../services/staffSettings');
@@ -19,12 +19,11 @@ async function handleDiscordReply(m) {
const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
if (!ticket) return;
- // Track whether last message is from staff or customer
const memberForCheck = await m.guild.members.fetch(m.author.id).catch(() => null);
- const isStaffMember = memberForCheck && CONFIG.ROLE_ID_TO_PING && memberForCheck.roles.cache.has(CONFIG.ROLE_ID_TO_PING);
+ const isStaffMember = isStaff(memberForCheck);
Ticket.updateOne(
{ discordThreadId: m.channel.id },
- { $set: { lastMessageAuthorIsStaff: !!isStaffMember, lastActivity: new Date() } }
+ { $set: { lastActivity: new Date() } }
).catch(() => {});
// DM the claimer if they have notifydm on and a non-staff user replied.
diff --git a/models.js b/models.js
index fbd7eb9..f09aa0a 100644
--- a/models.js
+++ b/models.js
@@ -5,8 +5,6 @@ var mongoose = require('mongoose');
const ticketSchema = new mongoose.Schema({
gmailThreadId: { type: String, required: true, unique: true, index: true },
discordThreadId: String,
- broccoliniTicketId: Number,
- lastSyncedBroccoliniArticleId: Number,
senderEmail: { type: String, required: true },
subject: String,
createdAt: { type: Date, default: Date.now },
@@ -16,18 +14,12 @@ const ticketSchema = new mongoose.Schema({
escalated: { type: Boolean, default: false },
escalationTier: { type: Number, default: 0 },
ticketNumber: Number,
- renameCount: { type: Number, default: 0 },
- renameWindowStart: Date,
priority: { type: String, default: 'normal', enum: ['low', 'normal', 'medium', 'high'] },
ticketTag: String,
lastActivity: Date,
- reminderSent: { type: Boolean, default: false },
welcomeMessageId: String,
claimerId: String,
- staffChannelId: String,
parentCategoryId: String,
- unclaimedRemindersSent: { type: [Number], default: [] },
- lastMessageAuthorIsStaff: { type: Boolean, default: false },
pendingDelete: { type: Boolean, default: false }
});
ticketSchema.index({ status: 1, lastActivity: 1 });
diff --git a/package-lock.json b/package-lock.json
index 52451c0..dd15998 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,13 +11,13 @@
"dependencies": {
"discord.js": "^14.25.1",
"dotenv": "^17.2.4",
- "dotenv-expand": "^11.0.6",
"express": "^5.2.1",
"express-rate-limit": "^8.3.2",
"googleapis": "^171.4.0",
- "mongodb": "^7.1.0",
- "mongoose": "^6.12.0",
- "p-queue": "^6.6.2"
+ "mongoose": "^6.12.0"
+ },
+ "devDependencies": {
+ "mongodb": "^7.1.0"
}
},
"node_modules/@aws-crypto/sha256-browser": {
@@ -1277,6 +1277,7 @@
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.5.tgz",
"integrity": "sha512-k64Lbyb7ycCSXHSLzxVdb2xsKGPMvYZfCICXvDsI8Z65CeWQzTEKS4YmGbnqw+U9RBvLPTsB6UCmwkgsDTGWIw==",
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"sparse-bitfield": "^3.0.3"
@@ -1964,6 +1965,7 @@
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz",
"integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@types/webidl-conversions": "*"
@@ -2344,33 +2346,6 @@
"url": "https://dotenvx.com"
}
},
- "node_modules/dotenv-expand": {
- "version": "11.0.7",
- "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz",
- "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==",
- "license": "BSD-2-Clause",
- "dependencies": {
- "dotenv": "^16.4.5"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://dotenvx.com"
- }
- },
- "node_modules/dotenv-expand/node_modules/dotenv": {
- "version": "16.6.1",
- "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
- "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://dotenvx.com"
- }
- },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2466,11 +2441,6 @@
"node": ">= 0.6"
}
},
- "node_modules/eventemitter3": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
- "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
- },
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
@@ -3068,6 +3038,7 @@
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
+ "devOptional": true,
"license": "MIT"
},
"node_modules/merge-descriptors": {
@@ -3135,6 +3106,7 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.0.tgz",
"integrity": "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg==",
+ "dev": true,
"license": "Apache-2.0",
"dependencies": {
"@mongodb-js/saslprep": "^1.3.0",
@@ -3181,6 +3153,7 @@
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz",
"integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==",
+ "dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/whatwg-url": "^13.0.0",
@@ -3194,6 +3167,7 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz",
"integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==",
+ "dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=20.19.0"
@@ -3391,40 +3365,6 @@
"wrappy": "1"
}
},
- "node_modules/p-finally": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
- "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/p-queue": {
- "version": "6.6.2",
- "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
- "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
- "dependencies": {
- "eventemitter3": "^4.0.4",
- "p-timeout": "^3.2.0"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/p-timeout": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
- "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
- "dependencies": {
- "p-finally": "^1.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -3783,6 +3723,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"memory-pager": "^1.0.2"
@@ -3919,6 +3860,7 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
@@ -4014,6 +3956,7 @@
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
diff --git a/package.json b/package.json
index a9d933a..a73f59f 100644
--- a/package.json
+++ b/package.json
@@ -2,13 +2,13 @@
"dependencies": {
"discord.js": "^14.25.1",
"dotenv": "^17.2.4",
- "dotenv-expand": "^11.0.6",
"express": "^5.2.1",
"express-rate-limit": "^8.3.2",
"googleapis": "^171.4.0",
- "mongodb": "^7.1.0",
- "mongoose": "^6.12.0",
- "p-queue": "^6.6.2"
+ "mongoose": "^6.12.0"
+ },
+ "devDependencies": {
+ "mongodb": "^7.1.0"
},
"name": "broccolini-bot",
"version": "1.0.0",
diff --git a/routes/internalApi.js b/routes/internalApi.js
index beddf0f..019d19e 100644
--- a/routes/internalApi.js
+++ b/routes/internalApi.js
@@ -4,7 +4,6 @@ const { ChannelType } = require('discord.js');
const { CONFIG } = require('../config');
const { safeEqual } = require('../utils');
const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence');
-const { logSystem } = require('../services/debugLog');
const { ALLOWED_CONFIG_KEYS } = require('../services/configSchema');
const router = express.Router();
@@ -54,11 +53,6 @@ router.post('/config', express.json(), async (req, res) => {
return res.status(400).json({ error: `Disallowed config keys: ${rejected.join(', ')}` });
}
const result = applyConfigUpdates(updates);
- const errorSummary = result.errors.map(e => `${e.key}: ${e.error}`).join(', ');
- await logSystem('Config updated via settings UI', [
- { name: 'Keys updated', value: result.applied.join(', ') || 'none', inline: false },
- { name: 'Errors', value: errorSummary || 'none', inline: false }
- ]).catch(() => {});
// Partial success stays 200 so the client can still apply the successful keys.
// Only 400 when every submitted key failed validation (i.e. the save did nothing).
const totalSubmitted = Object.keys(updates).length;
@@ -69,7 +63,7 @@ router.post('/config', express.json(), async (req, res) => {
// GET /discord/guild — return guild info for smart dropdowns
router.get('/discord/guild', async (req, res) => {
try {
- const client = require('../api/botClient').getBot();
+ const client = require('../broccolini-discord').client;
if (!client) return res.status(503).json({ error: 'Bot not ready' });
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
@@ -178,9 +172,6 @@ router.post('/gmail/reload', express.json(), async (req, res) => {
if (parent.setGmailPollInterval) {
parent.setGmailPollInterval(CONFIG.GMAIL_POLL_INTERVAL_MS);
}
- await logSystem('Gmail OAuth reloaded', [
- { name: 'Account', value: emailAddress, inline: false }
- ]).catch(() => {});
res.json({ ok: true, email: emailAddress });
} catch (err) {
const oauthError = err && err.response && err.response.data && err.response.data.error;
diff --git a/services/configSchema.js b/services/configSchema.js
index 93e00a9..b5111b2 100644
--- a/services/configSchema.js
+++ b/services/configSchema.js
@@ -19,8 +19,7 @@
const ALLOWED_CONFIG_KEYS = new Set([
// Ticket settings
- 'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'TICKET_T2_CATEGORY_NAME', 'TICKET_T3_CATEGORY_NAME',
- 'EMAIL_TICKET_OVERFLOW_CATEGORY_IDS', 'DISCORD_TICKET_CATEGORY_ID', 'DISCORD_TICKET_OVERFLOW_CATEGORY_IDS',
+ 'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'DISCORD_TICKET_CATEGORY_ID',
// Escalation categories
'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID',
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
@@ -29,12 +28,11 @@ const ALLOWED_CONFIG_KEYS = new Set([
'ADMIN_ID',
// Channel IDs
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
- 'DISCORD_CHANNEL_ID',
'RENAME_LOG_CHANNEL_ID',
// Messages and labels
'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE',
'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE',
- 'AUTO_CLOSE_MESSAGE', 'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE',
+ 'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE',
'REMINDER_MESSAGE', 'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM',
'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM',
// Branding
@@ -46,11 +44,11 @@ const ALLOWED_CONFIG_KEYS = new Set([
'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID',
'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE',
// Limits and thresholds
- 'GLOBAL_TICKET_LIMIT', 'TICKET_LIMIT_PER_CATEGORY',
+ 'GLOBAL_TICKET_LIMIT',
'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES',
'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS',
// Embed colors
- 'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLOSED', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
+ 'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI'
]);
@@ -201,32 +199,7 @@ function getValidator(key) {
return VALIDATORS[inferType(key)];
}
-// Pre-build per-key validator map for callers that want O(1) lookup
-// (and for the smoke test / boot log).
-const ALL_VALIDATORS = {};
-for (const key of ALLOWED_CONFIG_KEYS) {
- ALL_VALIDATORS[key] = getValidator(key);
-}
-
-// ---------- Startup log (no-op if console.log is suppressed) ----------
-
-(function logDistribution() {
- const dist = {};
- const fallback = [];
- for (const [key, v] of Object.entries(ALL_VALIDATORS)) {
- dist[v.type] = (dist[v.type] || 0) + 1;
- if (v.type === 'string') fallback.push(key);
- }
- console.log('[configSchema] type distribution:', JSON.stringify(dist));
- if (fallback.length) {
- console.log(`[configSchema] ${fallback.length} keys use fallback 'string' validator:`, fallback.join(', '));
- }
-})();
-
module.exports = {
ALLOWED_CONFIG_KEYS,
- VALIDATORS,
- ALL_VALIDATORS,
- getValidator,
- inferType
+ getValidator
};
diff --git a/services/debugLog.js b/services/debugLog.js
index 3e3e686..2b9a465 100644
--- a/services/debugLog.js
+++ b/services/debugLog.js
@@ -58,13 +58,6 @@ async function logWarn(context, message, overrideClient = null) {
await sendToChannel(CONFIG.DEBUGGING_CHANNEL_ID, embed, overrideClient);
}
-// --- logEvent (generic – posts to any configured channel) ---
-
-async function logEvent(channelConfigKey, embed, overrideClient = null) {
- const channelId = CONFIG[channelConfigKey];
- await sendToChannel(channelId, embed, overrideClient);
-}
-
// --- logTicketEvent ---
async function logTicketEvent(action, fields, interaction = null) {
@@ -79,46 +72,9 @@ async function logTicketEvent(action, fields, interaction = null) {
await sendToChannel(CONFIG.LOG_CHAN, embed, interaction?.client);
}
-// --- logGmail ---
-
-async function logGmail(...args) { return; }
-
-// --- logAutomation ---
-
-async function logAutomation(...args) { return; }
-
-// --- logSecurity ---
-
-async function logSecurity(...args) { return; }
-
-// --- logIntegrity ---
-
-async function logIntegrity(issue, detail, overrideClient = null) {
- const embed = new EmbedBuilder()
- .setTitle('Ticket Integrity Issue')
- .setColor(0xFF0000)
- .addFields(
- { name: 'Issue', value: String(issue).slice(0, 256), inline: false },
- { name: 'Detail', value: String(detail || 'N/A').slice(0, 1024), inline: false },
- { name: 'Timestamp', value: new Date().toISOString(), inline: true }
- )
- .setTimestamp();
- await sendToChannel(CONFIG.DEBUGGING_CHANNEL_ID, embed, overrideClient);
-}
-
-// --- logSystem ---
-
-async function logSystem(...args) { return; }
-
module.exports = {
setClient,
logError,
logWarn,
- logEvent,
- logTicketEvent,
- logGmail,
- logAutomation,
- logSecurity,
- logIntegrity,
- logSystem
+ logTicketEvent
};
diff --git a/services/gmail.js b/services/gmail.js
index 091ead1..8551218 100644
--- a/services/gmail.js
+++ b/services/gmail.js
@@ -1,5 +1,5 @@
/**
- * Gmail service – OAuth client, send reply, send ticket-closed email.
+ * Gmail service – OAuth client, send reply, send ticket-closed/notification emails.
*/
const { google } = require('googleapis');
const { CONFIG } = require('../config');
@@ -56,8 +56,6 @@ function getGmailClient() {
*
* Throws if the env file is missing the token, or if the probe call (getProfile)
* fails — the caller surfaces the error so the UI can see why.
- *
- * @returns {Promise<{emailAddress: string}>}
*/
async function reloadGmailClient() {
const envMap = readEnvFile();
@@ -74,276 +72,53 @@ async function reloadGmailClient() {
return { emailAddress: profile.data.emailAddress };
}
-async function sendTicketClosedEmail(ticket, closerName, userId = null) {
+// Fetch the first message's Subject + Message-ID from a Gmail thread, used to
+// derive a faithful Re: subject and a proper In-Reply-To/References header.
+async function fetchThreadSubjectAndMsgId(gmail, threadId) {
try {
- const gmail = getGmailClient();
-
- // Send to the ticket sender (customer), not derived from thread (which can be support)
- const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase();
- if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return;
- if (!EMAIL_RE.test(recipientEmail)) {
- logError('sendTicketClosedEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {});
- return;
- }
-
- let originalSubject = null;
- let msgId = null;
- try {
- const thread = await gmail.users.threads.get({
- userId: 'me',
- id: ticket.gmailThreadId
- });
- const messages = thread.data.messages || [];
- 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 (_) {}
-
- 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 htmlBody = `
-
${escapeHtml(messageBody).replace(/\n/g, '
')}
${safeStaffSigHtml}
` : ''} - ${buildCompanySigHtml()} -${escapeHtml(messageBody || '').replace(/\n/g, '
')}
${safeStaffSigHtml}
` : ''} - ${buildCompanySigHtml()} -${escapeHtml(replyText).replace(/\n/g, '
')}
${escapeHtml(messageText || '').replace(/\n/g, '
')}
${safeStaffSigHtml}
` : ''} ${buildCompanySigHtml()}