Add multi-account Gmail polling with per-account seen tracking, updated onboarding flow, and config/env resolution. Based on jasoncarreira's work in #214, rebased onto current main and cleaned up: - parseGmailAccounts() extracted to polling/service.ts with 10 unit tests - Per-account seen email tracking (Map<string, Set<string>>) with legacy migration from single-account format - Onboarding supports multi-select for existing accounts + add new - Config resolution: polling.gmail.accounts > integrations.google.accounts (legacy) > GMAIL_ACCOUNT env (comma-separated) - GoogleAccountConfig type for per-account service selection - Updated docs/configuration.md Closes #214. Written by Cameron ◯ Letta Code "Good artists copy, great artists steal." - Pablo Picasso
255 lines
8.4 KiB
TypeScript
255 lines
8.4 KiB
TypeScript
/**
|
|
* Polling Service
|
|
*
|
|
* System-level background polling for integrations (Gmail, etc.)
|
|
* Runs independently of agent cron jobs.
|
|
*/
|
|
|
|
import { spawnSync } from 'node:child_process';
|
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import type { AgentSession } from '../core/interfaces.js';
|
|
|
|
/**
|
|
* Parse Gmail accounts from a string (comma-separated) or string array.
|
|
* Deduplicates and trims whitespace.
|
|
*/
|
|
export function parseGmailAccounts(raw?: string | string[]): string[] {
|
|
if (!raw) return [];
|
|
const values = Array.isArray(raw) ? raw : raw.split(',');
|
|
const seen = new Set<string>();
|
|
for (const value of values) {
|
|
const trimmed = value.trim();
|
|
if (!trimmed || seen.has(trimmed)) continue;
|
|
seen.add(trimmed);
|
|
}
|
|
return Array.from(seen);
|
|
}
|
|
|
|
export interface PollingConfig {
|
|
intervalMs: number; // Polling interval in milliseconds
|
|
workingDir: string; // For persisting state
|
|
gmail?: {
|
|
enabled: boolean;
|
|
accounts: string[];
|
|
};
|
|
}
|
|
|
|
export class PollingService {
|
|
private intervalId: ReturnType<typeof setInterval> | null = null;
|
|
private bot: AgentSession;
|
|
private config: PollingConfig;
|
|
|
|
// Track seen email IDs per account to detect new emails (persisted to disk)
|
|
private seenEmailIdsByAccount: Map<string, Set<string>> = new Map();
|
|
private seenEmailsPath: string;
|
|
|
|
constructor(bot: AgentSession, config: PollingConfig) {
|
|
this.bot = bot;
|
|
this.config = config;
|
|
this.seenEmailsPath = join(config.workingDir, 'seen-emails.json');
|
|
this.loadSeenEmails();
|
|
}
|
|
|
|
/**
|
|
* Load seen email IDs from disk
|
|
*/
|
|
private loadSeenEmails(): void {
|
|
try {
|
|
if (existsSync(this.seenEmailsPath)) {
|
|
const data = JSON.parse(readFileSync(this.seenEmailsPath, 'utf-8'));
|
|
|
|
// New per-account format: { accounts: { "email": { ids: [...] } } }
|
|
if (data && typeof data === 'object' && data.accounts && typeof data.accounts === 'object') {
|
|
for (const [account, accountData] of Object.entries(data.accounts)) {
|
|
const ids = Array.isArray((accountData as { ids?: string[] }).ids)
|
|
? (accountData as { ids?: string[] }).ids!
|
|
: [];
|
|
this.seenEmailIdsByAccount.set(account, new Set(ids));
|
|
}
|
|
console.log(`[Polling] Loaded seen email IDs for ${this.seenEmailIdsByAccount.size} account(s)`);
|
|
return;
|
|
}
|
|
|
|
// Legacy single-account format: { ids: [...] }
|
|
if (data && Array.isArray(data.ids)) {
|
|
const accounts = this.config.gmail?.accounts || [];
|
|
const targetAccount = accounts[0];
|
|
if (targetAccount) {
|
|
this.seenEmailIdsByAccount.set(targetAccount, new Set(data.ids));
|
|
console.log(`[Polling] Migrated legacy seen emails to ${targetAccount}`);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('[Polling] Failed to load seen emails:', e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save seen email IDs to disk
|
|
*/
|
|
private saveSeenEmails(): void {
|
|
try {
|
|
const accounts: Record<string, { ids: string[]; updatedAt: string }> = {};
|
|
const now = new Date().toISOString();
|
|
for (const [account, ids] of this.seenEmailIdsByAccount.entries()) {
|
|
accounts[account] = {
|
|
ids: Array.from(ids),
|
|
updatedAt: now,
|
|
};
|
|
}
|
|
writeFileSync(this.seenEmailsPath, JSON.stringify({
|
|
accounts,
|
|
updatedAt: now,
|
|
}, null, 2));
|
|
} catch (e) {
|
|
console.error('[Polling] Failed to save seen emails:', e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start the polling service
|
|
*/
|
|
start(): void {
|
|
if (this.intervalId) {
|
|
console.log('[Polling] Already running');
|
|
return;
|
|
}
|
|
|
|
const enabledPollers: string[] = [];
|
|
if (this.config.gmail?.enabled) {
|
|
if (this.config.gmail.accounts.length > 0) {
|
|
enabledPollers.push(`Gmail (${this.config.gmail.accounts.length} account${this.config.gmail.accounts.length === 1 ? '' : 's'})`);
|
|
} else {
|
|
console.log('[Polling] Gmail enabled but no accounts configured');
|
|
}
|
|
}
|
|
|
|
if (enabledPollers.length === 0) {
|
|
console.log('[Polling] No pollers enabled');
|
|
return;
|
|
}
|
|
|
|
console.log(`[Polling] Starting (every ${this.config.intervalMs / 1000}s): ${enabledPollers.join(', ')}`);
|
|
|
|
// Run immediately on start
|
|
this.poll();
|
|
|
|
// Then run on interval
|
|
this.intervalId = setInterval(() => this.poll(), this.config.intervalMs);
|
|
}
|
|
|
|
/**
|
|
* Stop the polling service
|
|
*/
|
|
stop(): void {
|
|
if (this.intervalId) {
|
|
clearInterval(this.intervalId);
|
|
this.intervalId = null;
|
|
console.log('[Polling] Stopped');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run all enabled pollers
|
|
*/
|
|
private async poll(): Promise<void> {
|
|
if (this.config.gmail?.enabled) {
|
|
for (const account of this.config.gmail.accounts) {
|
|
await this.pollGmail(account);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Poll Gmail for new unread messages
|
|
*/
|
|
private async pollGmail(account: string): Promise<void> {
|
|
if (!account) return;
|
|
if (!this.seenEmailIdsByAccount.has(account)) {
|
|
this.seenEmailIdsByAccount.set(account, new Set());
|
|
}
|
|
const seenEmailIds = this.seenEmailIdsByAccount.get(account)!;
|
|
|
|
try {
|
|
// Check for unread emails (use longer window to catch any we might have missed)
|
|
const result = spawnSync('gog', [
|
|
'gmail', 'search',
|
|
'is:unread',
|
|
'--account', account,
|
|
'--max', '20'
|
|
], {
|
|
encoding: 'utf-8',
|
|
timeout: 30000,
|
|
});
|
|
|
|
if (result.status !== 0) {
|
|
console.log(`[Polling] Gmail check failed for ${account}: ${result.stderr || 'unknown error'}`);
|
|
return;
|
|
}
|
|
|
|
const output = result.stdout?.trim() || '';
|
|
const lines = output.split('\n').filter(l => l.trim());
|
|
|
|
// Parse email IDs from output (first column after header)
|
|
// Format: ID DATE FROM SUBJECT LABELS THREAD
|
|
const currentEmailIds = new Set<string>();
|
|
const newEmails: string[] = [];
|
|
|
|
for (let i = 1; i < lines.length; i++) { // Skip header
|
|
const line = lines[i];
|
|
const id = line.split(/\s+/)[0]; // First column is ID
|
|
if (id) {
|
|
currentEmailIds.add(id);
|
|
if (!seenEmailIds.has(id)) {
|
|
newEmails.push(line);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add new IDs to seen set (don't replace - we want to remember all seen emails)
|
|
for (const id of currentEmailIds) {
|
|
seenEmailIds.add(id);
|
|
}
|
|
this.saveSeenEmails();
|
|
|
|
// Only notify if there are NEW emails we haven't seen before
|
|
if (newEmails.length === 0) {
|
|
console.log(`[Polling] No new emails for ${account} (${currentEmailIds.size} unread total)`);
|
|
return;
|
|
}
|
|
|
|
console.log(`[Polling] Found ${newEmails.length} NEW email(s) for ${account}!`);
|
|
|
|
// Build output with header + new emails only
|
|
const header = lines[0];
|
|
const newEmailsOutput = [header, ...newEmails].join('\n');
|
|
|
|
// Send to agent for processing (SILENT MODE - no auto-delivery)
|
|
// Agent must use `lettabot-message` CLI to notify user
|
|
const message = [
|
|
'╔════════════════════════════════════════════════════════════════╗',
|
|
'║ [SILENT MODE] - Your text output is NOT sent to anyone. ║',
|
|
'║ To send a message, use: lettabot-message send --text "..." ║',
|
|
'╚════════════════════════════════════════════════════════════════╝',
|
|
'',
|
|
`[email] ${newEmails.length} new unread email(s) for ${account}:`,
|
|
'',
|
|
newEmailsOutput,
|
|
'',
|
|
'Review and summarize important emails. Use `lettabot-message send --text "..."` to notify the user if needed.',
|
|
].join('\n');
|
|
|
|
const response = await this.bot.sendToAgent(message);
|
|
|
|
// Log response but do NOT auto-deliver (silent mode)
|
|
console.log(`[Polling] Agent finished (SILENT MODE)`);
|
|
console.log(` - Response: ${response?.slice(0, 100)}${(response?.length || 0) > 100 ? '...' : ''}`);
|
|
console.log(` - (Response NOT auto-delivered - agent uses lettabot-message CLI)`)
|
|
} catch (e) {
|
|
console.error(`[Polling] Gmail error for ${account}:`, e);
|
|
}
|
|
}
|
|
}
|