/** * 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, PermissionFlagsBits } = require('discord.js'); const { mongoose, withRetry } = require('./db-connection'); const { CONFIG } = require('./config'); const { getCleanBody, extractRawEmail, stripEmailQuotes, stripMobileFooter, detectGame, sanitizeEmbedText } = require('./utils'); const { getGmailClient } = require('./services/gmail'); const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets'); const { logError } = require('./services/debugLog'); const { enqueueSend } = require('./services/channelQueue'); const { getTicketActionRow } = require('./utils/ticketComponents'); const Ticket = mongoose.model('Ticket'); const Transcript = mongoose.model('Transcript'); let isPolling = false; let authErrorNotified = false; let pollSuspended = false; function setPollSuspended(val) { pollSuspended = !!val; if (!pollSuspended) authErrorNotified = false; } 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, // Email tickets have no Discord creator — the customer is reachable // only by email. So the only per-channel allow is the staff role; we // still explicitly deny @everyone in case the category permissions // are ever misconfigured to grant View Channel server-wide. permissionOverwrites: [ { id: guild.id, deny: [PermissionFlagsBits.ViewChannel] }, ...(CONFIG.ROLE_ID_TO_PING ? [{ id: CONFIG.ROLE_ID_TO_PING, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory ] }] : []) ] }); 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 */ async function poll(client) { if (isPolling || pollSuspended) return; isPolling = true; 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; const guild = locateGuild(client); if (!guild) return; for (const msgRef of list.data.messages) { const email = await gmail.users.messages.get({ userId: 'me', id: msgRef.id }); const parsed = parseGmailMessage(email); if (parsed.isSelf) { await markGmailMessageRead(gmail, msgRef); continue; } 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 (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; } const { number } = await getNextTicketNumber(parsed.senderEmail); const created = await findOrCreateTicketChannel(guild, parsed, number); if (!created) { await markGmailMessageRead(gmail, msgRef); continue; } 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'] } }); 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: [] } }); // 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: parsed.threadId }, { $set: { discordThreadId: ticketChan.id, senderEmail: parsed.senderEmail, subject: parsed.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); } authErrorNotified = false; } catch (e) { oauthSuspendIfPermanent(e, client); console.error('POLL ERROR:', e); logError('Gmail poll', e, null, client).catch(() => {}); } } finally { isPolling = false; } } module.exports = { poll, setPollSuspended, isPollSuspended };