/** * Gmail polling – fetches unread emails and creates/updates Discord ticket channels. */ const { ChannelType, EmbedBuilder } = 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; } /** * 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; 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({ userId: 'me', id: msgRef.id }); 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 subject = 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') .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) { 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}`, allowedMentions: { parse: ['roles'] } } ); } 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; } } 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; } const detectedGame = detectGame(subject, rawBody); 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] }); } } } } } 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. 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 gmail.users.messages.batchModify({ userId: 'me', requestBody: { ids: [msgRef.id], removeLabelIds: ['UNREAD', 'INBOX'] } }); } 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; } } module.exports = { poll, setPollSuspended, isPollSuspended };