feat: add Gmail Pub/Sub watcher for inbound email automation
New ChannelAdapter that monitors Gmail via Google Cloud Pub/Sub push notifications with polling fallback. Supports OAuth2 auth, configurable watch labels, template rendering with email metadata placeholders (from, to, subject, snippet, date, id, labels). Wired into daemon lifecycle and gateway (POST /gmail/push endpoint). Includes 16 tests covering auth, templates, push notifications, and channel routing.
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { homedir } from 'os';
|
||||
import { GmailWatcher } from './gmail.js';
|
||||
import type { OutboundMessage } from '../channels/types.js';
|
||||
|
||||
// Mock googleapis module
|
||||
vi.mock('googleapis', () => {
|
||||
const mockWatch = vi.fn().mockResolvedValue({
|
||||
data: { historyId: '12345', expiration: String(Date.now() + 7 * 24 * 60 * 60 * 1000) },
|
||||
});
|
||||
|
||||
const mockHistoryList = vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
history: [
|
||||
{
|
||||
messagesAdded: [
|
||||
{ message: { id: 'msg-001' } },
|
||||
{ message: { id: 'msg-002' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
historyId: '12346',
|
||||
},
|
||||
});
|
||||
|
||||
const mockMessagesGet = vi.fn().mockImplementation(({ id }: { id: string }) => {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
id,
|
||||
snippet: 'Hello, this is a test email',
|
||||
labelIds: ['INBOX', 'UNREAD'],
|
||||
payload: {
|
||||
headers: [
|
||||
{ name: 'From', value: 'alice@example.com' },
|
||||
{ name: 'To', value: 'bob@example.com' },
|
||||
{ name: 'Subject', value: 'Test Subject' },
|
||||
{ name: 'Date', value: 'Sat, 07 Feb 2026 10:00:00 +0000' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const mockGetProfile = vi.fn().mockResolvedValue({
|
||||
data: { emailAddress: 'bob@example.com', historyId: '12344' },
|
||||
});
|
||||
|
||||
const mockOAuth2 = vi.fn().mockImplementation(() => ({
|
||||
setCredentials: vi.fn(),
|
||||
on: vi.fn(),
|
||||
}));
|
||||
|
||||
return {
|
||||
google: {
|
||||
auth: {
|
||||
OAuth2: mockOAuth2,
|
||||
},
|
||||
gmail: vi.fn().mockReturnValue({
|
||||
users: {
|
||||
watch: mockWatch,
|
||||
getProfile: mockGetProfile,
|
||||
history: { list: mockHistoryList },
|
||||
messages: { get: mockMessagesGet },
|
||||
},
|
||||
}),
|
||||
},
|
||||
_mocks: {
|
||||
mockWatch,
|
||||
mockHistoryList,
|
||||
mockMessagesGet,
|
||||
mockGetProfile,
|
||||
mockOAuth2,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock fs operations
|
||||
vi.mock('fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('fs')>('fs');
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
readFileSync: vi.fn().mockImplementation((path: string) => {
|
||||
if (path.includes('credentials')) {
|
||||
return JSON.stringify({
|
||||
installed: {
|
||||
client_id: 'test-client-id',
|
||||
client_secret: 'test-client-secret',
|
||||
redirect_uris: ['http://localhost'],
|
||||
},
|
||||
});
|
||||
}
|
||||
// Token file
|
||||
return JSON.stringify({
|
||||
access_token: 'test-access-token',
|
||||
refresh_token: 'test-refresh-token',
|
||||
expiry_date: Date.now() + 3600000,
|
||||
});
|
||||
}),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
chmodSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
function createMockConfig(overrides = {}) {
|
||||
return {
|
||||
enabled: true,
|
||||
credentials_file: '~/.config/flynn/gmail-credentials.json',
|
||||
token_file: '~/.config/flynn/gmail-token.json',
|
||||
watch_labels: ['INBOX'],
|
||||
poll_interval: '60s',
|
||||
output: {
|
||||
channel: 'telegram',
|
||||
peer: '12345',
|
||||
},
|
||||
message: 'New email from {{from}}: {{subject}}\n\n{{snippet}}',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockChannelLookup() {
|
||||
const mockSend = vi.fn().mockResolvedValue(undefined);
|
||||
return {
|
||||
get: vi.fn().mockReturnValue({ send: mockSend }),
|
||||
_mockSend: mockSend,
|
||||
};
|
||||
}
|
||||
|
||||
describe('GmailWatcher', () => {
|
||||
let watcher: GmailWatcher;
|
||||
let channelLookup: ReturnType<typeof createMockChannelLookup>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
channelLookup = createMockChannelLookup();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (watcher) {
|
||||
await watcher.disconnect();
|
||||
}
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('construction', () => {
|
||||
it('creates with valid config', () => {
|
||||
const config = createMockConfig();
|
||||
watcher = new GmailWatcher(config, channelLookup);
|
||||
|
||||
expect(watcher.name).toBe('gmail');
|
||||
expect(watcher.status).toBe('disconnected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('connect() with missing credentials', () => {
|
||||
it('logs warning and sets status to error when credentials_file is missing', async () => {
|
||||
const config = createMockConfig({ credentials_file: undefined });
|
||||
watcher = new GmailWatcher(config, channelLookup);
|
||||
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await watcher.connect();
|
||||
|
||||
expect(watcher.status).toBe('error');
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('GmailWatcher: Authorization failed'),
|
||||
);
|
||||
|
||||
// The actual error includes instructions
|
||||
const calls = errorSpy.mock.calls.flat().join(' ');
|
||||
expect(calls).toContain('flynn gmail-auth');
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('logs warning when token file does not exist', async () => {
|
||||
const { existsSync } = await import('fs');
|
||||
const mockExistsSync = vi.mocked(existsSync);
|
||||
// credentials file exists but token file does not
|
||||
mockExistsSync.mockImplementation((path: unknown) => {
|
||||
const p = String(path);
|
||||
if (p.includes('credentials')) return true;
|
||||
if (p.includes('token')) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const config = createMockConfig();
|
||||
watcher = new GmailWatcher(config, channelLookup);
|
||||
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
await watcher.connect();
|
||||
|
||||
expect(watcher.status).toBe('error');
|
||||
const calls = errorSpy.mock.calls.flat().join(' ');
|
||||
expect(calls).toContain('flynn gmail-auth');
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderTemplate', () => {
|
||||
it('replaces all placeholders correctly', () => {
|
||||
const config = createMockConfig({
|
||||
message: 'From: {{from}} To: {{to}} Subject: {{subject}} Snippet: {{snippet}} Date: {{date}} ID: {{id}} Labels: {{labels}}',
|
||||
});
|
||||
watcher = new GmailWatcher(config, channelLookup);
|
||||
|
||||
const email = {
|
||||
id: 'msg-123',
|
||||
from: 'alice@example.com',
|
||||
to: 'bob@example.com',
|
||||
subject: 'Hello!',
|
||||
snippet: 'How are you?',
|
||||
date: '2026-02-07T10:00:00Z',
|
||||
labels: ['INBOX', 'UNREAD'],
|
||||
};
|
||||
|
||||
const result = watcher.renderTemplate(email);
|
||||
|
||||
expect(result).toBe(
|
||||
'From: alice@example.com To: bob@example.com Subject: Hello! Snippet: How are you? Date: 2026-02-07T10:00:00Z ID: msg-123 Labels: INBOX, UNREAD',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles missing fields gracefully', () => {
|
||||
const config = createMockConfig({
|
||||
message: '{{from}}: {{subject}}',
|
||||
});
|
||||
watcher = new GmailWatcher(config, channelLookup);
|
||||
|
||||
const email = {
|
||||
id: '',
|
||||
from: '',
|
||||
to: '',
|
||||
subject: '',
|
||||
snippet: '',
|
||||
date: '',
|
||||
labels: [],
|
||||
};
|
||||
|
||||
const result = watcher.renderTemplate(email);
|
||||
expect(result).toBe(': ');
|
||||
});
|
||||
|
||||
it('replaces multiple occurrences of the same placeholder', () => {
|
||||
const config = createMockConfig({
|
||||
message: '{{from}} sent: {{subject}} (from {{from}})',
|
||||
});
|
||||
watcher = new GmailWatcher(config, channelLookup);
|
||||
|
||||
const email = {
|
||||
id: 'msg-1',
|
||||
from: 'alice@example.com',
|
||||
to: 'bob@example.com',
|
||||
subject: 'Hi',
|
||||
snippet: '',
|
||||
date: '',
|
||||
labels: [],
|
||||
};
|
||||
|
||||
const result = watcher.renderTemplate(email);
|
||||
expect(result).toBe('alice@example.com sent: Hi (from alice@example.com)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandPath', () => {
|
||||
it('expands ~ to homedir', () => {
|
||||
const config = createMockConfig();
|
||||
watcher = new GmailWatcher(config, channelLookup);
|
||||
|
||||
const result = watcher.expandPath('~/some/path');
|
||||
expect(result).toBe(`${homedir()}/some/path`);
|
||||
});
|
||||
|
||||
it('expands bare ~', () => {
|
||||
const config = createMockConfig();
|
||||
watcher = new GmailWatcher(config, channelLookup);
|
||||
|
||||
const result = watcher.expandPath('~');
|
||||
expect(result).toBe(homedir());
|
||||
});
|
||||
|
||||
it('resolves absolute paths unchanged', () => {
|
||||
const config = createMockConfig();
|
||||
watcher = new GmailWatcher(config, channelLookup);
|
||||
|
||||
const result = watcher.expandPath('/tmp/test.json');
|
||||
expect(result).toBe('/tmp/test.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePushNotification', () => {
|
||||
it('decodes base64 data and updates historyId', async () => {
|
||||
const config = createMockConfig();
|
||||
watcher = new GmailWatcher(config, channelLookup);
|
||||
|
||||
// Set initial historyId via direct access
|
||||
(watcher as unknown as { lastHistoryId: string }).lastHistoryId = '100';
|
||||
|
||||
const notification = { emailAddress: 'bob@example.com', historyId: '200' };
|
||||
const encoded = Buffer.from(JSON.stringify(notification)).toString('base64');
|
||||
|
||||
await watcher.handlePushNotification(encoded);
|
||||
|
||||
// historyId should be updated
|
||||
expect((watcher as unknown as { lastHistoryId: string }).lastHistoryId).toBe('200');
|
||||
});
|
||||
|
||||
it('ignores stale historyId', async () => {
|
||||
const config = createMockConfig();
|
||||
watcher = new GmailWatcher(config, channelLookup);
|
||||
|
||||
(watcher as unknown as { lastHistoryId: string }).lastHistoryId = '200';
|
||||
|
||||
const notification = { emailAddress: 'bob@example.com', historyId: '100' };
|
||||
const encoded = Buffer.from(JSON.stringify(notification)).toString('base64');
|
||||
|
||||
await watcher.handlePushNotification(encoded);
|
||||
|
||||
// historyId should NOT change
|
||||
expect((watcher as unknown as { lastHistoryId: string }).lastHistoryId).toBe('200');
|
||||
});
|
||||
|
||||
it('handles invalid base64 gracefully', async () => {
|
||||
const config = createMockConfig();
|
||||
watcher = new GmailWatcher(config, channelLookup);
|
||||
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await watcher.handlePushNotification('not-valid-json-after-decode!!!');
|
||||
|
||||
// Should not throw — just log error
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disconnect', () => {
|
||||
it('clears timers and sets status to disconnected', async () => {
|
||||
const config = createMockConfig();
|
||||
watcher = new GmailWatcher(config, channelLookup);
|
||||
|
||||
// Manually set status and timers
|
||||
(watcher as unknown as { _status: string })._status = 'connected';
|
||||
(watcher as unknown as { pollTimer: ReturnType<typeof setInterval> }).pollTimer = setInterval(() => {}, 60000);
|
||||
(watcher as unknown as { watchTimer: ReturnType<typeof setInterval> }).watchTimer = setInterval(() => {}, 60000);
|
||||
|
||||
await watcher.disconnect();
|
||||
|
||||
expect(watcher.status).toBe('disconnected');
|
||||
expect((watcher as unknown as { pollTimer: unknown }).pollTimer).toBeUndefined();
|
||||
expect((watcher as unknown as { watchTimer: unknown }).watchTimer).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('send', () => {
|
||||
it('routes to output channel via ChannelLookup', async () => {
|
||||
const config = createMockConfig();
|
||||
watcher = new GmailWatcher(config, channelLookup);
|
||||
|
||||
const message: OutboundMessage = { text: 'Reply text' };
|
||||
|
||||
await watcher.send('some-peer', message);
|
||||
|
||||
expect(channelLookup.get).toHaveBeenCalledWith('telegram');
|
||||
expect(channelLookup._mockSend).toHaveBeenCalledWith('12345', message);
|
||||
});
|
||||
|
||||
it('warns when output channel not found', async () => {
|
||||
const config = createMockConfig();
|
||||
const emptyLookup = { get: vi.fn().mockReturnValue(undefined) };
|
||||
watcher = new GmailWatcher(config, emptyLookup);
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await watcher.send('some-peer', { text: 'test' });
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Output channel 'telegram' not found"),
|
||||
);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onMessage', () => {
|
||||
it('registers message handler', () => {
|
||||
const config = createMockConfig();
|
||||
watcher = new GmailWatcher(config, channelLookup);
|
||||
|
||||
const handler = vi.fn();
|
||||
watcher.onMessage(handler);
|
||||
|
||||
// Verify handler is stored (indirectly, via processHistoryChanges)
|
||||
expect((watcher as unknown as { messageHandler: unknown }).messageHandler).toBe(handler);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user