audit week 2 [VIBE-001]: decompose gmail-poll.js poll()

Split the original 309-line poll() into single-responsibility helpers and a
thin orchestrator. No behavior change — every Gmail API call, Discord call,
DB write, and log line stays in the same order with the same arguments.

Helpers extracted (module-private):
- locateGuild(client) — DISCORD_GUILD_ID lookup with fallback warning.
- parseGmailMessage(email) — header parsing, body decode, dual cleanup
  (firstBody for new-ticket message, followupBody for thread append).
- findOrCreateTicketChannel(guild, parsed, number) — category resolution
  + channel.create with the existing two-stage error handling.
- linkPreviousTranscripts(ticketChan, threadId, client) — best-effort
  prior-transcript link on reopen.
- markGmailMessageRead(gmail, msgRef) — wraps the batchModify call used
  in five places across the original.
- oauthSuspendIfPermanent(err, client) — invalid_grant/invalid_client
  classify, suspend polling, clear interval, DM admin once. Returns bool.

poll() is now the orchestrator: list → locate guild → for each message,
parse → look up existing ticket → branch (append-followup vs new-ticket
flow) → mark read. The new-ticket branch stays inline in poll() per the
"keep poll() as orchestration" intent.
This commit is contained in:
2026-05-08 20:23:30 +00:00
parent cdf85f6364
commit d0cf8fd915

View File

@@ -1,5 +1,9 @@
/** /**
* Gmail polling fetches unread emails and creates/updates Discord ticket channels. * Gmail polling fetches unread emails and creates/updates Discord ticket channels.
*
* `poll()` is the orchestrator: list → locate guild → for each message,
* parse → look up existing → branch (append-followup vs create-ticket) → mark read.
* Each step delegates to a single-responsibility helper below.
*/ */
const { const {
ChannelType, ChannelType,
@@ -35,6 +39,213 @@ function setPollSuspended(val) {
} }
function isPollSuspended() { return pollSuspended; } function isPollSuspended() { return pollSuspended; }
// ============================================================
// Helpers (extracted from the original 309-line poll()).
// ============================================================
/**
* Pick the guild for this poll iteration. Honors DISCORD_GUILD_ID when set,
* otherwise falls back to the first guild in the cache. Returns null with a
* warning if no usable guild is available; caller should bail.
*/
function locateGuild(client) {
if (CONFIG.DISCORD_GUILD_ID) {
const g = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
if (!g) {
console.warn('Configured guild not found for DISCORD_GUILD_ID:', CONFIG.DISCORD_GUILD_ID);
}
return g || null;
}
const g = client.guilds.cache.first();
if (!g) {
console.warn('No guilds in cache; skipping poll iteration.');
}
return g || null;
}
/**
* Parse a Gmail message payload into normalized fields.
*
* Body cleanup runs twice with different rules:
* - firstBody: aggressive — strip quotes if it looks like a reply, strip
* mobile footers, collapse newlines. Used as the first message in a new
* ticket channel where we want only the user's actual message.
* - followupBody: defensive — strip quotes but fall back to raw text if
* stripping leaves nothing. Used for follow-up posts on an existing thread.
*/
function parseGmailMessage(email) {
const headers = email.data.payload.headers;
const from = headers.find(h => h.name === 'From')?.value || '';
const isSelf = from.toLowerCase().includes(CONFIG.MY_EMAIL);
const subject = headers.find(h => h.name === 'Subject')?.value || 'New Ticket';
const rawBody = getCleanBody(email.data.payload);
const senderEmail = extractRawEmail(from).toLowerCase();
const senderName = (from.match(/^(.*?)\s*<.*>$/) || [null, from])[1]
?.replace(/"/g, '')
.trim() || 'Unknown';
const hasReplyHeaderFrom = /(?:\n{2,}|(?:^|\n)--\s*\n)[^\S\r\n]*From:\s.*<.*@.*>/i.test(rawBody);
const looksLikeReply = /\nOn .+wrote:/i.test(rawBody) || hasReplyHeaderFrom;
let firstBody = rawBody.replace(/\r\n/g, '\n');
if (looksLikeReply) firstBody = stripEmailQuotes(firstBody);
firstBody = stripMobileFooter(firstBody);
firstBody = firstBody.replace(/^\s*\n+/g, '');
firstBody = firstBody.replace(/\n{3,}/g, '\n\n');
firstBody = firstBody
.replace(/Get Outlook for [^\n]+/gi, '')
.replace(/<\s*$/gm, '')
.trim();
const rawText = rawBody.replace(/\r\n/g, '\n');
let followupBody = stripEmailQuotes(rawText);
if (!followupBody.trim()) followupBody = rawText;
followupBody = followupBody.replace(/^\s*\n*/, '\n');
followupBody = followupBody.replace(/\n{3,}/g, '\n\n');
followupBody = stripMobileFooter(followupBody);
followupBody = followupBody
.replace(/Get Outlook for [^\n]+/gi, '')
.replace(/<\s*$/gm, '')
.trim();
return {
isSelf,
threadId: email.data.threadId,
from,
subject,
rawBody,
senderEmail,
senderName,
firstBody,
followupBody
};
}
/**
* Resolve the parent category and create a fresh ticket channel under it.
* Returns { channel, parentCategoryId } on success, or null on failure (caller
* should mark the message read and skip — same behavior as the original inline path).
*/
async function findOrCreateTicketChannel(guild, parsed, number) {
const creatorNickname = getSenderLocal(parsed.senderEmail);
const chanName = toDiscordSafeName(`unclaimed-${creatorNickname}-${number}`);
let parentCategoryId;
try {
parentCategoryId = await getOrCreateTicketCategory(
guild,
CONFIG.TICKET_CATEGORY_ID,
CONFIG.TICKET_CATEGORY_NAME
);
} catch (err) {
console.error('Channel create error (payload):', {
message: err.message,
code: err.code,
rawError: err.rawError
});
return null;
}
try {
const channel = await guild.channels.create({
name: chanName,
type: ChannelType.GuildText,
parent: parentCategoryId
});
return { channel, parentCategoryId };
} catch (createErr) {
console.error('Channel create error (email ticket):', createErr);
return null;
}
}
/**
* Post links + attachments for prior transcripts of a reopened thread.
* Best-effort: any failure is logged and swallowed so the new ticket flow
* continues unaffected.
*/
async function linkPreviousTranscripts(ticketChan, threadId, client) {
try {
const transcriptRows = await Transcript.find({ gmailThreadId: threadId })
.sort({ createdAt: 1 })
.select('transcriptMessageId')
.lean();
if (transcriptRows.length === 0) return;
const transcriptChan = await client.channels
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
.catch(() => null);
if (!transcriptChan) return;
await enqueueSend(
ticketChan,
`This email thread has ${transcriptRows.length} previous transcript(s):`
);
for (const row of transcriptRows) {
const transcriptMsg = await transcriptChan.messages
.fetch(row.transcriptMessageId)
.catch(() => null);
if (!transcriptMsg) continue;
await enqueueSend(ticketChan, `Transcript: ${transcriptMsg.url}`);
const originalAttachment = transcriptMsg.attachments.first();
if (originalAttachment) {
await enqueueSend(ticketChan, {
content: 'Transcript file:',
files: [originalAttachment.url]
});
}
}
} catch (err) {
console.error('Error linking previous transcripts:', err);
}
}
/** Drop UNREAD + INBOX labels from a Gmail message — equivalent to "archive + read". */
async function markGmailMessageRead(gmail, msgRef) {
await gmail.users.messages.batchModify({
userId: 'me',
requestBody: {
ids: [msgRef.id],
removeLabelIds: ['UNREAD', 'INBOX']
}
});
}
/**
* If the error indicates a permanent OAuth-grant failure (invalid_grant /
* invalid_client), suspend polling, clear the recurring poll interval, log,
* and DM the admin once. Returns true iff polling was suspended (caller
* should not treat as a transient retry-on-next-tick error).
*
* Transient 401/403/429/5xx and network errors are NOT considered permanent —
* they fall through to the next interval naturally. The OAuth code lives on
* `err.response.data.error`, not the message string.
*/
function oauthSuspendIfPermanent(err, client) {
const oauthError = err && err.response && err.response.data && err.response.data.error;
const isPermanentAuth = oauthError === 'invalid_grant' || oauthError === 'invalid_client';
if (!isPermanentAuth) return false;
pollSuspended = true;
const suspendMsg = `Gmail OAuth ${oauthError}. Polling SUSPENDED — will not retry automatically. Re-authenticate and POST /internal/gmail/reload to resume.`;
console.error('[gmail-poll]', suspendMsg);
logError('Gmail OAuth', { message: suspendMsg, stack: err.stack || err.message || String(err) }, null, client).catch(() => {});
try { require('./broccolini-discord').clearGmailPollInterval?.(); } catch (_) {}
if (CONFIG.ADMIN_ID && !authErrorNotified) {
authErrorNotified = true;
client.users.fetch(CONFIG.ADMIN_ID).then(u => u.send(suspendMsg)).catch(() => {});
}
return true;
}
// ============================================================
// Orchestrator
// ============================================================
/** /**
* Poll Gmail for unread primary-inbox messages and route them to Discord. * Poll Gmail for unread primary-inbox messages and route them to Discord.
* @param {import('discord.js').Client} client * @param {import('discord.js').Client} client
@@ -52,88 +263,19 @@ async function poll(client) {
}); });
if (!list.data.messages) return; if (!list.data.messages) return;
let guild; const guild = locateGuild(client);
if (CONFIG.DISCORD_GUILD_ID) { if (!guild) return;
guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
if (!guild) {
console.warn(
'Configured guild not found for DISCORD_GUILD_ID:',
CONFIG.DISCORD_GUILD_ID
);
return;
}
} else {
guild = client.guilds.cache.first();
if (!guild) {
console.warn('No guilds in cache; skipping poll iteration.');
return;
}
}
for (const msgRef of list.data.messages) { for (const msgRef of list.data.messages) {
const email = await gmail.users.messages.get({ const email = await gmail.users.messages.get({ userId: 'me', id: msgRef.id });
userId: 'me', const parsed = parseGmailMessage(email);
id: msgRef.id
});
const from = if (parsed.isSelf) {
email.data.payload.headers.find(h => h.name === 'From') await markGmailMessageRead(gmail, msgRef);
?.value || '';
if (from.toLowerCase().includes(CONFIG.MY_EMAIL)) {
await gmail.users.messages.batchModify({
userId: 'me',
requestBody: {
ids: [msgRef.id],
removeLabelIds: ['UNREAD', 'INBOX']
}
});
continue; continue;
} }
const subject = const existing = await Ticket.findOne({ gmailThreadId: parsed.threadId })
email.data.payload.headers.find(h => h.name === 'Subject')
?.value || 'New Ticket';
const rawBody = getCleanBody(email.data.payload);
const sEmail = extractRawEmail(from).toLowerCase();
const sName =
(from.match(/^(.*?)\s*<.*>$/) || [null, from])[1]
?.replace(/"/g, '')
.trim() || 'Unknown';
const hasReplyHeaderFrom =
/(?:\n{2,}|(?:^|\n)--\s*\n)[^\S\r\n]*From:\s.*<.*@.*>/i.test(rawBody);
const looksLikeReply =
/\nOn .+wrote:/i.test(rawBody) ||
hasReplyHeaderFrom;
let firstBodyText = rawBody.replace(/\r\n/g, '\n');
if (looksLikeReply) {
firstBodyText = stripEmailQuotes(firstBodyText);
}
firstBodyText = stripMobileFooter(firstBodyText);
firstBodyText = firstBodyText.replace(/^\s*\n+/g, '');
firstBodyText = firstBodyText.replace(/\n{3,}/g, '\n\n');
firstBodyText = firstBodyText
.replace(/Get Outlook for [^\n]+/gi, '')
.replace(/<\s*$/gm, '')
.trim();
const firstBody = firstBodyText;
const rawText = rawBody.replace(/\r\n/g, '\n');
let followupBody = stripEmailQuotes(rawText);
if (!followupBody.trim()) {
followupBody = rawText;
}
followupBody = followupBody.replace(/^\s*\n*/, '\n');
followupBody = followupBody.replace(/\n{3,}/g, '\n\n');
followupBody = stripMobileFooter(followupBody);
followupBody = followupBody
.replace(/Get Outlook for [^\n]+/gi, '')
.replace(/<\s*$/gm, '')
.trim();
const existing = await Ticket.findOne({ gmailThreadId: email.data.threadId })
.select('gmailThreadId discordThreadId status') .select('gmailThreadId discordThreadId status')
.lean(); .lean();
@@ -142,86 +284,46 @@ async function poll(client) {
let isReopened = false; let isReopened = false;
if (existing && existing.discordThreadId) { if (existing && existing.discordThreadId) {
ticketChan = await guild.channels ticketChan = await guild.channels.fetch(existing.discordThreadId).catch(() => null);
.fetch(existing.discordThreadId)
.catch(() => null);
} else if (existing && existing.status === 'closed') { } else if (existing && existing.status === 'closed') {
isReopened = true; isReopened = true;
} }
if (ticketChan) { if (ticketChan) {
const truncatedFollowup = followupBody.slice(0, 1800); // Append follow-up to existing channel.
const truncatedFollowup = parsed.followupBody.slice(0, 1800);
// Role ping is intentional; body is attacker-controlled email content — suppress user/everyone mentions. // Role ping is intentional; body is attacker-controlled email content — suppress user/everyone mentions.
await enqueueSend( await enqueueSend(ticketChan, {
ticketChan, content: `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${parsed.senderEmail}:**\n${truncatedFollowup}`,
{
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${sEmail}:**\n${truncatedFollowup}`,
allowedMentions: { parse: ['roles'] } allowedMentions: { parse: ['roles'] }
} });
);
} else { } else {
// Check ticket limits before creating // Create a new ticket channel.
const limitCheck = await checkTicketLimits(sEmail); const limitCheck = await checkTicketLimits(parsed.senderEmail);
if (!limitCheck.ok) { if (!limitCheck.ok) {
console.log(`Ticket limit reached for ${sEmail}: ${limitCheck.reason}`); console.log(`Ticket limit reached for ${parsed.senderEmail}: ${limitCheck.reason}`);
await gmail.users.messages.batchModify({ await markGmailMessageRead(gmail, msgRef);
userId: 'me',
requestBody: {
ids: [msgRef.id],
removeLabelIds: ['UNREAD', 'INBOX']
}
});
continue; continue;
} }
const { number } = await getNextTicketNumber(sEmail); const { number } = await getNextTicketNumber(parsed.senderEmail);
const creatorNickname = getSenderLocal(sEmail); const created = await findOrCreateTicketChannel(guild, parsed, number);
const chanName = toDiscordSafeName(`unclaimed-${creatorNickname}-${number}`); if (!created) {
await markGmailMessageRead(gmail, msgRef);
try {
const parentId = await getOrCreateTicketCategory(
guild,
CONFIG.TICKET_CATEGORY_ID,
CONFIG.TICKET_CATEGORY_NAME
);
parentCategoryIdForTicket = parentId;
try {
ticketChan = await guild.channels.create({
name: chanName,
type: ChannelType.GuildText,
parent: parentId
});
} catch (createErr) {
console.error('Channel create error (email ticket):', createErr);
throw createErr;
}
} catch (err) {
console.error('Channel create error (payload):', {
message: err.message,
code: err.code,
rawError: err.rawError
});
await gmail.users.messages.batchModify({
userId: 'me',
requestBody: {
ids: [msgRef.id],
removeLabelIds: ['UNREAD', 'INBOX']
}
});
continue; continue;
} }
ticketChan = created.channel;
parentCategoryIdForTicket = created.parentCategoryId;
const detectedGame = detectGame(subject, rawBody); const detectedGame = detectGame(parsed.subject, parsed.rawBody);
const buttons = getTicketActionRow({ escalationTier: 0 }); const buttons = getTicketActionRow({ escalationTier: 0 });
const ticketInfoEmbed = new EmbedBuilder() const ticketInfoEmbed = new EmbedBuilder()
.setColor(CONFIG.EMBED_COLOR_INFO) .setColor(CONFIG.EMBED_COLOR_INFO)
.addFields( .addFields(
{ name: 'Name', value: `\`\`\`\n${sanitizeEmbedText(sName)}\n\`\`\``, inline: false }, { name: 'Name', value: `\`\`\`\n${sanitizeEmbedText(parsed.senderName)}\n\`\`\``, inline: false },
{ name: 'Email', value: `\`\`\`\n${sanitizeEmbedText(sEmail)}\n\`\`\``, inline: false }, { name: 'Email', value: `\`\`\`\n${sanitizeEmbedText(parsed.senderEmail)}\n\`\`\``, inline: false },
{ name: 'Game', value: `\`\`\`\n${sanitizeEmbedText(detectedGame)}\n\`\`\``, inline: false }, { name: 'Game', value: `\`\`\`\n${sanitizeEmbedText(detectedGame)}\n\`\`\``, inline: false },
{ name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(subject) || 'No subject'}\n\`\`\``, inline: false } { name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(parsed.subject) || 'No subject'}\n\`\`\``, inline: false }
); );
const welcomeMsg = await enqueueSend(ticketChan, { const welcomeMsg = await enqueueSend(ticketChan, {
@@ -239,66 +341,29 @@ async function poll(client) {
await pinMessage(welcomeMsg, client).catch(() => {}); await pinMessage(welcomeMsg, client).catch(() => {});
} }
// On reopen, link previous transcripts
if (isReopened) { if (isReopened) {
try { await linkPreviousTranscripts(ticketChan, parsed.threadId, client);
const transcriptRows = await Transcript.find({ gmailThreadId: email.data.threadId })
.sort({ createdAt: 1 })
.select('transcriptMessageId')
.lean();
if (transcriptRows.length > 0) {
const transcriptChan = await client.channels
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
.catch(() => null);
if (transcriptChan) {
await enqueueSend(
ticketChan,
`This email thread has ${transcriptRows.length} previous transcript(s):`
);
for (const row of transcriptRows) {
const transcriptMsg = await transcriptChan.messages
.fetch(row.transcriptMessageId)
.catch(() => null);
if (!transcriptMsg) continue;
await enqueueSend(ticketChan, `Transcript: ${transcriptMsg.url}`);
const originalAttachment = transcriptMsg.attachments.first();
if (originalAttachment) {
await enqueueSend(ticketChan, {
content: 'Transcript file:',
files: [originalAttachment.url]
});
}
}
}
}
} catch (err) {
console.error('Error linking previous transcripts:', err);
}
} }
const truncated = firstBody.slice(0, 1900);
// Email body is attacker-controlled — no mentions may fire from its content. // Email body is attacker-controlled — no mentions may fire from its content.
await enqueueSend(ticketChan, { content: `**Message:**\n${truncated}`, allowedMentions: { parse: [] } }); const truncated = parsed.firstBody.slice(0, 1900);
await enqueueSend(ticketChan, {
content: `**Message:**\n${truncated}`,
allowedMentions: { parse: [] }
});
// Welcome message skipped for email tickets the email body speaks for itself. // Welcome message skipped for email tickets the email body speaks for itself.
// Panel-created (Discord) tickets still send the welcome message in handlers/buttons.js. // Panel-created (Discord) tickets still send the welcome message in handlers/buttons.js.
const now = new Date(); const now = new Date();
const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal'; const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal';
await withRetry(() => Ticket.findOneAndUpdate( await withRetry(() => Ticket.findOneAndUpdate(
{ gmailThreadId: email.data.threadId }, { gmailThreadId: parsed.threadId },
{ {
$set: { $set: {
discordThreadId: ticketChan.id, discordThreadId: ticketChan.id,
senderEmail: sEmail, senderEmail: parsed.senderEmail,
subject, subject: parsed.subject,
createdAt: now, createdAt: now,
status: 'open', status: 'open',
ticketNumber: number, ticketNumber: number,
@@ -310,36 +375,13 @@ async function poll(client) {
{ upsert: true, new: true } { upsert: true, new: true }
)); ));
} }
console.log('Archiving/reading Gmail message', msgRef.id); console.log('Archiving/reading Gmail message', msgRef.id);
await gmail.users.messages.batchModify({ await markGmailMessageRead(gmail, msgRef);
userId: 'me',
requestBody: {
ids: [msgRef.id],
removeLabelIds: ['UNREAD', 'INBOX']
}
});
} }
authErrorNotified = false; authErrorNotified = false;
} catch (e) { } catch (e) {
// Only treat Google-reported permanent-grant failures as reasons to suspend oauthSuspendIfPermanent(e, client);
// the loop. Transient 401/403/429/5xx/network errors fall through to the
// next interval tick naturally. The OAuth error codes come back on the
// response body, not the message string.
const oauthError = e && e.response && e.response.data && e.response.data.error;
const isPermanentAuth = oauthError === 'invalid_grant' || oauthError === 'invalid_client';
if (isPermanentAuth) {
pollSuspended = true;
const suspendMsg = `Gmail OAuth ${oauthError}. Polling SUSPENDED — will not retry automatically. Re-authenticate and POST /internal/gmail/reload to resume.`;
console.error('[gmail-poll]', suspendMsg);
logError('Gmail OAuth', { message: suspendMsg, stack: e.stack || e.message || String(e) }, null, client).catch(() => {});
try { require('./broccolini-discord').clearGmailPollInterval?.(); } catch (_) {}
if (CONFIG.ADMIN_ID && !authErrorNotified) {
authErrorNotified = true;
client.users.fetch(CONFIG.ADMIN_ID).then(u => u.send(suspendMsg)).catch(() => {});
}
}
console.error('POLL ERROR:', e); console.error('POLL ERROR:', e);
logError('Gmail poll', e, null, client).catch(() => {}); logError('Gmail poll', e, null, client).catch(() => {});
} }