diff --git a/gmail-poll.js b/gmail-poll.js index a45ab8d..5ca2136 100644 --- a/gmail-poll.js +++ b/gmail-poll.js @@ -1,9 +1,13 @@ /** * 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 { ChannelType, - + EmbedBuilder } = require('discord.js'); const { mongoose, withRetry } = require('./db-connection'); @@ -35,6 +39,213 @@ function setPollSuspended(val) { } 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. * @param {import('discord.js').Client} client @@ -45,304 +256,135 @@ async function poll(client) { try { console.log('Running poll()...'); try { - const gmail = getGmailClient(); - const list = await gmail.users.messages.list({ - userId: 'me', - q: 'is:unread category:primary' - }); - if (!list.data.messages) return; - - let guild; - if (CONFIG.DISCORD_GUILD_ID) { - 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) { - const email = await gmail.users.messages.get({ + const gmail = getGmailClient(); + const list = await gmail.users.messages.list({ userId: 'me', - id: msgRef.id + q: 'is:unread category:primary' }); + if (!list.data.messages) return; - const from = - email.data.payload.headers.find(h => h.name === 'From') - ?.value || ''; - if (from.toLowerCase().includes(CONFIG.MY_EMAIL)) { - await gmail.users.messages.batchModify({ - userId: 'me', - requestBody: { - ids: [msgRef.id], - removeLabelIds: ['UNREAD', 'INBOX'] - } - }); - continue; - } + const guild = locateGuild(client); + if (!guild) return; - const subject = - email.data.payload.headers.find(h => h.name === 'Subject') - ?.value || 'New Ticket'; - const rawBody = getCleanBody(email.data.payload); + for (const msgRef of list.data.messages) { + const email = await gmail.users.messages.get({ userId: 'me', id: msgRef.id }); + const parsed = parseGmailMessage(email); - const sEmail = extractRawEmail(from).toLowerCase(); - const sName = - (from.match(/^(.*?)\s*<.*>$/) || [null, from])[1] - ?.replace(/"/g, '') - .trim() || 'Unknown'; + if (parsed.isSelf) { + await markGmailMessageRead(gmail, msgRef); + continue; + } - 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') - .lean(); + const existing = await Ticket.findOne({ gmailThreadId: parsed.threadId }) + .select('gmailThreadId discordThreadId status') + .lean(); let ticketChan = null; let parentCategoryIdForTicket = null; let isReopened = false; - if (existing && existing.discordThreadId) { - ticketChan = await guild.channels - .fetch(existing.discordThreadId) - .catch(() => null); - } else if (existing && existing.status === 'closed') { - isReopened = true; - } + if (existing && existing.discordThreadId) { + ticketChan = await guild.channels.fetch(existing.discordThreadId).catch(() => null); + } else if (existing && existing.status === 'closed') { + isReopened = true; + } - if (ticketChan) { - const truncatedFollowup = followupBody.slice(0, 1800); - // Role ping is intentional; body is attacker-controlled email content — suppress user/everyone mentions. - await enqueueSend( - ticketChan, - { - content: `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${sEmail}:**\n${truncatedFollowup}`, + if (ticketChan) { + // 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. + await enqueueSend(ticketChan, { + content: `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${parsed.senderEmail}:**\n${truncatedFollowup}`, allowedMentions: { parse: ['roles'] } + }); + } else { + // Create a new ticket channel. + const limitCheck = await checkTicketLimits(parsed.senderEmail); + if (!limitCheck.ok) { + console.log(`Ticket limit reached for ${parsed.senderEmail}: ${limitCheck.reason}`); + await markGmailMessageRead(gmail, msgRef); + continue; } - ); - } else { - // Check ticket limits before creating - const limitCheck = await checkTicketLimits(sEmail); - if (!limitCheck.ok) { - console.log(`Ticket limit reached for ${sEmail}: ${limitCheck.reason}`); - await gmail.users.messages.batchModify({ - userId: 'me', - requestBody: { - ids: [msgRef.id], - removeLabelIds: ['UNREAD', 'INBOX'] - } - }); - continue; - } - const { number } = await getNextTicketNumber(sEmail); - const creatorNickname = getSenderLocal(sEmail); - const chanName = toDiscordSafeName(`unclaimed-${creatorNickname}-${number}`); - - 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; + const { number } = await getNextTicketNumber(parsed.senderEmail); + const created = await findOrCreateTicketChannel(guild, parsed, number); + if (!created) { + await markGmailMessageRead(gmail, msgRef); + continue; } - } catch (err) { - console.error('Channel create error (payload):', { - message: err.message, - code: err.code, - rawError: err.rawError + ticketChan = created.channel; + parentCategoryIdForTicket = created.parentCategoryId; + + const detectedGame = detectGame(parsed.subject, parsed.rawBody); + const buttons = getTicketActionRow({ escalationTier: 0 }); + const ticketInfoEmbed = new EmbedBuilder() + .setColor(CONFIG.EMBED_COLOR_INFO) + .addFields( + { name: 'Name', value: `\`\`\`\n${sanitizeEmbedText(parsed.senderName)}\n\`\`\``, inline: false }, + { name: 'Email', value: `\`\`\`\n${sanitizeEmbedText(parsed.senderEmail)}\n\`\`\``, inline: false }, + { name: 'Game', value: `\`\`\`\n${sanitizeEmbedText(detectedGame)}\n\`\`\``, inline: false }, + { name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(parsed.subject) || 'No subject'}\n\`\`\``, inline: false } + ); + + const welcomeMsg = await enqueueSend(ticketChan, { + content: `<@&${CONFIG.ROLE_ID_TO_PING}>`, + embeds: [ticketInfoEmbed], + components: [buttons], + allowedMentions: { parse: ['roles'] } }); - await gmail.users.messages.batchModify({ - userId: 'me', - requestBody: { - ids: [msgRef.id], - removeLabelIds: ['UNREAD', 'INBOX'] - } + + const { createStaffThread } = require('./services/staffThread'); + await createStaffThread(ticketChan, client).catch(() => {}); + + if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) { + const { pinMessage } = require('./services/pinMessage'); + await pinMessage(welcomeMsg, client).catch(() => {}); + } + + if (isReopened) { + await linkPreviousTranscripts(ticketChan, parsed.threadId, client); + } + + // Email body is attacker-controlled — no mentions may fire from its content. + const truncated = parsed.firstBody.slice(0, 1900); + await enqueueSend(ticketChan, { + content: `**Message:**\n${truncated}`, + allowedMentions: { parse: [] } }); - continue; - } - const detectedGame = detectGame(subject, rawBody); + // 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. - const buttons = getTicketActionRow({ escalationTier: 0 }); - - const ticketInfoEmbed = new EmbedBuilder() - .setColor(CONFIG.EMBED_COLOR_INFO) - .addFields( - { name: 'Name', value: `\`\`\`\n${sanitizeEmbedText(sName)}\n\`\`\``, inline: false }, - { name: 'Email', value: `\`\`\`\n${sanitizeEmbedText(sEmail)}\n\`\`\``, inline: false }, - { name: 'Game', value: `\`\`\`\n${sanitizeEmbedText(detectedGame)}\n\`\`\``, inline: false }, - { name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(subject) || 'No subject'}\n\`\`\``, inline: false } - ); - - const welcomeMsg = await enqueueSend(ticketChan, { - content: `<@&${CONFIG.ROLE_ID_TO_PING}>`, - embeds: [ticketInfoEmbed], - components: [buttons], - allowedMentions: { parse: ['roles'] } - }); - - const { createStaffThread } = require('./services/staffThread'); - await createStaffThread(ticketChan, client).catch(() => {}); - - if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) { - const { pinMessage } = require('./services/pinMessage'); - await pinMessage(welcomeMsg, client).catch(() => {}); - } - - // On reopen, link previous transcripts - if (isReopened) { - try { - 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] - }); - } - } + const now = new Date(); + const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal'; + await withRetry(() => Ticket.findOneAndUpdate( + { gmailThreadId: parsed.threadId }, + { + $set: { + discordThreadId: ticketChan.id, + senderEmail: parsed.senderEmail, + subject: parsed.subject, + createdAt: now, + status: 'open', + ticketNumber: number, + priority: defaultPriority, + lastActivity: now, + parentCategoryId: parentCategoryIdForTicket } - } - } catch (err) { - console.error('Error linking previous transcripts:', err); - } + }, + { upsert: true, new: true } + )); } - const truncated = firstBody.slice(0, 1900); - // Email body is attacker-controlled — no mentions may fire from its content. - await enqueueSend(ticketChan, { content: `**Message:**\n${truncated}`, allowedMentions: { parse: [] } }); - - // 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. - - const now = new Date(); - const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal'; - - await withRetry(() => Ticket.findOneAndUpdate( - { gmailThreadId: email.data.threadId }, - { - $set: { - discordThreadId: ticketChan.id, - senderEmail: sEmail, - subject, - createdAt: now, - status: 'open', - ticketNumber: number, - priority: defaultPriority, - lastActivity: now, - parentCategoryId: parentCategoryIdForTicket - } - }, - { upsert: true, new: true } - )); + console.log('Archiving/reading Gmail message', msgRef.id); + await markGmailMessageRead(gmail, msgRef); } - console.log('Archiving/reading Gmail message', msgRef.id); - await gmail.users.messages.batchModify({ - userId: 'me', - requestBody: { - ids: [msgRef.id], - removeLabelIds: ['UNREAD', 'INBOX'] - } - }); + authErrorNotified = false; + } catch (e) { + oauthSuspendIfPermanent(e, client); + console.error('POLL ERROR:', e); + logError('Gmail poll', e, null, client).catch(() => {}); } - authErrorNotified = false; - } catch (e) { - // Only treat Google-reported permanent-grant failures as reasons to suspend - // 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); - logError('Gmail poll', e, null, client).catch(() => {}); - } } finally { isPolling = false; }