refactor: restructure add-gmail skill for new skill architecture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Fritzzzz
2026-02-24 22:29:34 +02:00
parent 1c31726c63
commit 41e2424856
14 changed files with 2714 additions and 611 deletions

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GmailChannel, GmailChannelOpts } from './gmail.js';
function makeOpts(overrides?: Partial<GmailChannelOpts>): GmailChannelOpts {
return {
onMessage: vi.fn(),
onChatMetadata: vi.fn(),
registeredGroups: () => ({}),
...overrides,
};
}
describe('GmailChannel', () => {
let channel: GmailChannel;
beforeEach(() => {
channel = new GmailChannel(makeOpts());
});
describe('ownsJid', () => {
it('returns true for gmail: prefixed JIDs', () => {
expect(channel.ownsJid('gmail:abc123')).toBe(true);
expect(channel.ownsJid('gmail:thread-id-456')).toBe(true);
});
it('returns false for non-gmail JIDs', () => {
expect(channel.ownsJid('12345@g.us')).toBe(false);
expect(channel.ownsJid('tg:123')).toBe(false);
expect(channel.ownsJid('dc:456')).toBe(false);
expect(channel.ownsJid('user@s.whatsapp.net')).toBe(false);
});
});
describe('name', () => {
it('is gmail', () => {
expect(channel.name).toBe('gmail');
});
});
describe('isConnected', () => {
it('returns false before connect', () => {
expect(channel.isConnected()).toBe(false);
});
});
describe('disconnect', () => {
it('sets connected to false', async () => {
await channel.disconnect();
expect(channel.isConnected()).toBe(false);
});
});
describe('constructor options', () => {
it('accepts custom poll interval', () => {
const ch = new GmailChannel(makeOpts(), 30000);
expect(ch.name).toBe('gmail');
});
it('defaults to unread query when no filter configured', () => {
const ch = new GmailChannel(makeOpts());
const query = (ch as unknown as { buildQuery: () => string }).buildQuery();
expect(query).toBe('is:unread category:primary');
});
it('defaults with no options provided', () => {
const ch = new GmailChannel(makeOpts());
expect(ch.name).toBe('gmail');
});
});
});

View File

@@ -0,0 +1,326 @@
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 { ASSISTANT_NAME, 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<string, RegisteredGroup>;
}
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<typeof setInterval> | null = null;
private processedIds = new Set<string>();
private threadMeta = new Map<string, ThreadMeta>();
private userEmail = '';
constructor(
opts: GmailChannelOpts,
pollIntervalMs = 60000,
) {
this.opts = opts;
this.pollIntervalMs = pollIntervalMs;
}
async connect(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 '';
}
}