import fs from 'fs'; import os from 'os'; import path from 'path'; import { google, gmail_v1 } from 'googleapis'; import { OAuth2Client } from 'google-auth-library'; import { MAIN_GROUP_FOLDER } from '../config.js'; import { logger } from '../logger.js'; import { Channel, OnChatMetadata, OnInboundMessage, RegisteredGroup, } from '../types.js'; export interface GmailChannelOpts { onMessage: OnInboundMessage; onChatMetadata: OnChatMetadata; registeredGroups: () => Record; } interface ThreadMeta { sender: string; senderName: string; subject: string; messageId: string; // RFC 2822 Message-ID for In-Reply-To } export class GmailChannel implements Channel { name = 'gmail'; private oauth2Client: OAuth2Client | null = null; private gmail: gmail_v1.Gmail | null = null; private opts: GmailChannelOpts; private pollIntervalMs: number; private pollTimer: ReturnType | null = null; private processedIds = new Set(); private threadMeta = new Map(); private userEmail = ''; constructor(opts: GmailChannelOpts, pollIntervalMs = 60000) { this.opts = opts; this.pollIntervalMs = pollIntervalMs; } async connect(): Promise { const credDir = path.join(os.homedir(), '.gmail-mcp'); const keysPath = path.join(credDir, 'gcp-oauth.keys.json'); const tokensPath = path.join(credDir, 'credentials.json'); if (!fs.existsSync(keysPath) || !fs.existsSync(tokensPath)) { throw new Error( 'Gmail credentials not found in ~/.gmail-mcp/. Run Gmail OAuth setup first.', ); } const keys = JSON.parse(fs.readFileSync(keysPath, 'utf-8')); const tokens = JSON.parse(fs.readFileSync(tokensPath, 'utf-8')); const clientConfig = keys.installed || keys.web || keys; const { client_id, client_secret, redirect_uris } = clientConfig; this.oauth2Client = new google.auth.OAuth2( client_id, client_secret, redirect_uris?.[0], ); this.oauth2Client.setCredentials(tokens); // Persist refreshed tokens this.oauth2Client.on('tokens', (newTokens) => { try { const current = JSON.parse(fs.readFileSync(tokensPath, 'utf-8')); Object.assign(current, newTokens); fs.writeFileSync(tokensPath, JSON.stringify(current, null, 2)); logger.debug('Gmail OAuth tokens refreshed'); } catch (err) { logger.warn({ err }, 'Failed to persist refreshed Gmail tokens'); } }); this.gmail = google.gmail({ version: 'v1', auth: this.oauth2Client }); // Verify connection const profile = await this.gmail.users.getProfile({ userId: 'me' }); this.userEmail = profile.data.emailAddress || ''; logger.info({ email: this.userEmail }, 'Gmail channel connected'); // Start polling this.pollTimer = setInterval( () => this.pollForMessages().catch((err) => logger.error({ err }, 'Gmail poll error'), ), this.pollIntervalMs, ); // Initial poll await this.pollForMessages(); } async sendMessage(jid: string, text: string): Promise { if (!this.gmail) { logger.warn('Gmail not initialized'); return; } const threadId = jid.replace(/^gmail:/, ''); const meta = this.threadMeta.get(threadId); if (!meta) { logger.warn({ jid }, 'No thread metadata for reply, cannot send'); return; } const subject = meta.subject.startsWith('Re:') ? meta.subject : `Re: ${meta.subject}`; const headers = [ `To: ${meta.sender}`, `From: ${this.userEmail}`, `Subject: ${subject}`, `In-Reply-To: ${meta.messageId}`, `References: ${meta.messageId}`, 'Content-Type: text/plain; charset=utf-8', '', text, ].join('\r\n'); const encodedMessage = Buffer.from(headers) .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); try { await this.gmail.users.messages.send({ userId: 'me', requestBody: { raw: encodedMessage, threadId, }, }); logger.info({ to: meta.sender, threadId }, 'Gmail reply sent'); } catch (err) { logger.error({ jid, err }, 'Failed to send Gmail reply'); } } isConnected(): boolean { return this.gmail !== null; } ownsJid(jid: string): boolean { return jid.startsWith('gmail:'); } async disconnect(): Promise { if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; } this.gmail = null; this.oauth2Client = null; logger.info('Gmail channel stopped'); } // --- Private --- private buildQuery(): string { return 'is:unread category:primary'; } private async pollForMessages(): Promise { if (!this.gmail) return; try { const query = this.buildQuery(); const res = await this.gmail.users.messages.list({ userId: 'me', q: query, maxResults: 10, }); const messages = res.data.messages || []; for (const stub of messages) { if (!stub.id || this.processedIds.has(stub.id)) continue; this.processedIds.add(stub.id); await this.processMessage(stub.id); } // Cap processed ID set to prevent unbounded growth if (this.processedIds.size > 5000) { const ids = [...this.processedIds]; this.processedIds = new Set(ids.slice(ids.length - 2500)); } } catch (err) { logger.error({ err }, 'Gmail poll failed'); } } private async processMessage(messageId: string): Promise { if (!this.gmail) return; const msg = await this.gmail.users.messages.get({ userId: 'me', id: messageId, format: 'full', }); const headers = msg.data.payload?.headers || []; const getHeader = (name: string) => headers.find((h) => h.name?.toLowerCase() === name.toLowerCase()) ?.value || ''; const from = getHeader('From'); const subject = getHeader('Subject'); const rfc2822MessageId = getHeader('Message-ID'); const threadId = msg.data.threadId || messageId; const timestamp = new Date( parseInt(msg.data.internalDate || '0', 10), ).toISOString(); // Extract sender name and email const senderMatch = from.match(/^(.+?)\s*<(.+?)>$/); const senderName = senderMatch ? senderMatch[1].replace(/"/g, '') : from; const senderEmail = senderMatch ? senderMatch[2] : from; // Skip emails from self (our own replies) if (senderEmail === this.userEmail) return; // Extract body text const body = this.extractTextBody(msg.data.payload); if (!body) { logger.debug({ messageId, subject }, 'Skipping email with no text body'); return; } const chatJid = `gmail:${threadId}`; // Cache thread metadata for replies this.threadMeta.set(threadId, { sender: senderEmail, senderName, subject, messageId: rfc2822MessageId, }); // Store chat metadata for group discovery this.opts.onChatMetadata(chatJid, timestamp, subject, 'gmail', false); // Find the main group to deliver the email notification const groups = this.opts.registeredGroups(); const mainEntry = Object.entries(groups).find( ([, g]) => g.folder === MAIN_GROUP_FOLDER, ); if (!mainEntry) { logger.debug( { chatJid, subject }, 'No main group registered, skipping email', ); return; } const mainJid = mainEntry[0]; const content = `[Email from ${senderName} <${senderEmail}>]\nSubject: ${subject}\n\n${body}`; this.opts.onMessage(mainJid, { id: messageId, chat_jid: mainJid, sender: senderEmail, sender_name: senderName, content, timestamp, is_from_me: false, }); // Mark as read try { await this.gmail.users.messages.modify({ userId: 'me', id: messageId, requestBody: { removeLabelIds: ['UNREAD'] }, }); } catch (err) { logger.warn({ messageId, err }, 'Failed to mark email as read'); } logger.info( { mainJid, from: senderName, subject }, 'Gmail email delivered to main group', ); } private extractTextBody( payload: gmail_v1.Schema$MessagePart | undefined, ): string { if (!payload) return ''; // Direct text/plain body if (payload.mimeType === 'text/plain' && payload.body?.data) { return Buffer.from(payload.body.data, 'base64').toString('utf-8'); } // Multipart: search parts recursively if (payload.parts) { // Prefer text/plain for (const part of payload.parts) { if (part.mimeType === 'text/plain' && part.body?.data) { return Buffer.from(part.body.data, 'base64').toString('utf-8'); } } // Recurse into nested multipart for (const part of payload.parts) { const text = this.extractTextBody(part); if (text) return text; } } return ''; } }