feat: support multiple Gmail accounts for polling (#244)

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
This commit is contained in:
Cameron
2026-02-09 16:58:34 -08:00
committed by GitHub
parent deb1c4532a
commit f2ec8f60c2
7 changed files with 316 additions and 94 deletions

View File

@@ -387,15 +387,18 @@ polling:
intervalMs: 60000 # Check every 60 seconds (default: 60000) intervalMs: 60000 # Check every 60 seconds (default: 60000)
gmail: gmail:
enabled: true enabled: true
account: user@example.com # Gmail account to poll accounts: # Gmail accounts to poll
- user@example.com
- other@example.com
``` ```
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| |--------|------|---------|-------------|
| `polling.enabled` | boolean | auto | Master switch. Defaults to `true` if any sub-config is enabled | | `polling.enabled` | boolean | auto | Master switch. Defaults to `true` if any sub-config is enabled |
| `polling.intervalMs` | number | `60000` | Polling interval in milliseconds | | `polling.intervalMs` | number | `60000` | Polling interval in milliseconds |
| `polling.gmail.enabled` | boolean | auto | Enable Gmail polling. Auto-detected from `account` | | `polling.gmail.enabled` | boolean | auto | Enable Gmail polling. Auto-detected from `account` or `accounts` |
| `polling.gmail.account` | string | - | Gmail account to poll for unread messages | | `polling.gmail.account` | string | - | Gmail account to poll for unread messages |
| `polling.gmail.accounts` | string[] | - | Gmail accounts to poll for unread messages |
### Legacy config path ### Legacy config path
@@ -405,7 +408,9 @@ For backward compatibility, Gmail polling can also be configured under `integrat
integrations: integrations:
google: google:
enabled: true enabled: true
account: user@example.com accounts:
- account: user@example.com
services: [gmail, calendar]
pollIntervalSec: 60 pollIntervalSec: 60
``` ```
@@ -415,7 +420,7 @@ The top-level `polling` section takes priority if both are present.
| Env Variable | Polling Config Equivalent | | Env Variable | Polling Config Equivalent |
|--------------|--------------------------| |--------------|--------------------------|
| `GMAIL_ACCOUNT` | `polling.gmail.account` | | `GMAIL_ACCOUNT` | `polling.gmail.account` (comma-separated list allowed) |
| `POLLING_INTERVAL_MS` | `polling.intervalMs` | | `POLLING_INTERVAL_MS` | `polling.intervalMs` |
| `PORT` | `api.port` | | `PORT` | `api.port` |
| `API_HOST` | `api.host` | | `API_HOST` | `api.host` |
@@ -547,7 +552,7 @@ Environment variables override config file values:
| `WHATSAPP_SELF_CHAT_MODE` | `channels.whatsapp.selfChat` | | `WHATSAPP_SELF_CHAT_MODE` | `channels.whatsapp.selfChat` |
| `SIGNAL_PHONE_NUMBER` | `channels.signal.phone` | | `SIGNAL_PHONE_NUMBER` | `channels.signal.phone` |
| `OPENAI_API_KEY` | `transcription.apiKey` | | `OPENAI_API_KEY` | `transcription.apiKey` |
| `GMAIL_ACCOUNT` | `polling.gmail.account` | | `GMAIL_ACCOUNT` | `polling.gmail.account` (comma-separated list allowed) |
| `POLLING_INTERVAL_MS` | `polling.intervalMs` | | `POLLING_INTERVAL_MS` | `polling.intervalMs` |
See [SKILL.md](../SKILL.md) for complete environment variable reference. See [SKILL.md](../SKILL.md) for complete environment variable reference.

View File

@@ -210,16 +210,26 @@ export function configToEnv(config: LettaBotConfig): Record<string, string> {
} }
// Polling - top-level polling config (preferred) // Polling - top-level polling config (preferred)
if (config.polling?.gmail?.enabled && config.polling.gmail.account) { if (config.polling?.gmail?.enabled) {
env.GMAIL_ACCOUNT = config.polling.gmail.account; const accounts = config.polling.gmail.accounts !== undefined
? config.polling.gmail.accounts
: (config.polling.gmail.account ? [config.polling.gmail.account] : []);
if (accounts.length > 0) {
env.GMAIL_ACCOUNT = accounts.join(',');
}
} }
if (config.polling?.intervalMs) { if (config.polling?.intervalMs) {
env.POLLING_INTERVAL_MS = String(config.polling.intervalMs); env.POLLING_INTERVAL_MS = String(config.polling.intervalMs);
} }
// Integrations - Google (legacy path for Gmail polling, lower priority) // Integrations - Google (legacy path for Gmail polling, lower priority)
if (!env.GMAIL_ACCOUNT && config.integrations?.google?.enabled && config.integrations.google.account) { if (!env.GMAIL_ACCOUNT && config.integrations?.google?.enabled) {
env.GMAIL_ACCOUNT = config.integrations.google.account; const legacyAccounts = config.integrations.google.accounts
? config.integrations.google.accounts.map(a => a.account)
: (config.integrations.google.account ? [config.integrations.google.account] : []);
if (legacyAccounts.length > 0) {
env.GMAIL_ACCOUNT = legacyAccounts.join(',');
}
} }
if (!env.POLLING_INTERVAL_MS && config.integrations?.google?.pollIntervalSec) { if (!env.POLLING_INTERVAL_MS && config.integrations?.google?.pollIntervalSec) {
env.POLLING_INTERVAL_MS = String(config.integrations.google.pollIntervalSec * 1000); env.POLLING_INTERVAL_MS = String(config.integrations.google.pollIntervalSec * 1000);

View File

@@ -131,6 +131,7 @@ export interface PollingYamlConfig {
gmail?: { gmail?: {
enabled?: boolean; // Enable Gmail polling enabled?: boolean; // Enable Gmail polling
account?: string; // Gmail account to poll (e.g., user@example.com) account?: string; // Gmail account to poll (e.g., user@example.com)
accounts?: string[]; // Multiple Gmail accounts to poll
}; };
} }
@@ -200,9 +201,15 @@ export interface DiscordConfig {
instantGroups?: string[]; // Guild/server IDs or channel IDs that bypass batching instantGroups?: string[]; // Guild/server IDs or channel IDs that bypass batching
} }
export interface GoogleAccountConfig {
account: string;
services?: string[]; // e.g., ['gmail', 'calendar', 'drive', 'contacts', 'docs', 'sheets']
}
export interface GoogleConfig { export interface GoogleConfig {
enabled: boolean; enabled: boolean;
account?: string; account?: string;
accounts?: GoogleAccountConfig[];
services?: string[]; // e.g., ['gmail', 'calendar', 'drive', 'contacts', 'docs', 'sheets'] services?: string[]; // e.g., ['gmail', 'calendar', 'drive', 'contacts', 'docs', 'sheets']
pollIntervalSec?: number; // Polling interval in seconds (default: 60) pollIntervalSec?: number; // Polling interval in seconds (default: 60)
} }

View File

@@ -150,7 +150,7 @@ import { DiscordAdapter } from './channels/discord.js';
import { GroupBatcher } from './core/group-batcher.js'; import { GroupBatcher } from './core/group-batcher.js';
import { CronService } from './cron/service.js'; import { CronService } from './cron/service.js';
import { HeartbeatService } from './cron/heartbeat.js'; import { HeartbeatService } from './cron/heartbeat.js';
import { PollingService } from './polling/service.js'; import { PollingService, parseGmailAccounts } from './polling/service.js';
import { agentExists, findAgentByName, ensureNoToolApprovals } from './tools/letta-api.js'; import { agentExists, findAgentByName, ensureNoToolApprovals } from './tools/letta-api.js';
// Skills are now installed to agent-scoped location after agent creation (see bot.ts) // Skills are now installed to agent-scoped location after agent creation (see bot.ts)
@@ -569,24 +569,42 @@ async function main() {
} }
bot.onTriggerHeartbeat = () => heartbeatService.trigger(); bot.onTriggerHeartbeat = () => heartbeatService.trigger();
// Per-agent polling // Per-agent polling -- resolve accounts from polling > integrations.google (legacy) > env
const pollConfig = agentConfig.polling || (agentConfig.integrations?.google ? { const pollConfig = (() => {
enabled: agentConfig.integrations.google.enabled, const pollingAccounts = parseGmailAccounts(
intervalMs: (agentConfig.integrations.google.pollIntervalSec || 60) * 1000, agentConfig.polling?.gmail?.accounts || agentConfig.polling?.gmail?.account
gmail: { );
enabled: agentConfig.integrations.google.enabled, const legacyAccounts = (() => {
account: agentConfig.integrations.google.account || '', const legacy = agentConfig.integrations?.google;
}, if (legacy?.accounts?.length) {
} : undefined); return parseGmailAccounts(legacy.accounts.map(a => a.account));
}
return parseGmailAccounts(legacy?.account);
})();
const envAccounts = parseGmailAccounts(process.env.GMAIL_ACCOUNT);
const gmailAccounts = pollingAccounts.length > 0
? pollingAccounts
: legacyAccounts.length > 0
? legacyAccounts
: envAccounts;
const gmailEnabled = agentConfig.polling?.gmail?.enabled
?? agentConfig.integrations?.google?.enabled
?? gmailAccounts.length > 0;
return {
enabled: agentConfig.polling?.enabled ?? gmailEnabled,
intervalMs: agentConfig.polling?.intervalMs
?? (agentConfig.integrations?.google?.pollIntervalSec
? agentConfig.integrations.google.pollIntervalSec * 1000
: 60000),
gmail: { enabled: gmailEnabled, accounts: gmailAccounts },
};
})();
if (pollConfig?.enabled && pollConfig.gmail?.enabled) { if (pollConfig.enabled && pollConfig.gmail.enabled && pollConfig.gmail.accounts.length > 0) {
const pollingService = new PollingService(bot, { const pollingService = new PollingService(bot, {
intervalMs: pollConfig.intervalMs || 60000, intervalMs: pollConfig.intervalMs,
workingDir: globalConfig.workingDir, workingDir: globalConfig.workingDir,
gmail: { gmail: pollConfig.gmail,
enabled: pollConfig.gmail.enabled,
account: pollConfig.gmail.account || '',
},
}); });
pollingService.start(); pollingService.start();
services.pollingServices.push(pollingService); services.pollingServices.push(pollingService);

View File

@@ -154,7 +154,7 @@ interface OnboardConfig {
discord: { enabled: boolean; token?: string; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[] }; discord: { enabled: boolean; token?: string; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[] };
// Google Workspace (via gog CLI) // Google Workspace (via gog CLI)
google: { enabled: boolean; account?: string; services?: string[] }; google: { enabled: boolean; accounts: Array<{ account: string; services: string[] }> };
// Features // Features
heartbeat: { enabled: boolean; interval?: string }; heartbeat: { enabled: boolean; interval?: string };
@@ -751,6 +751,7 @@ async function stepGoogle(config: OnboardConfig): Promise<void> {
if (!setupGoogle) { if (!setupGoogle) {
config.google.enabled = false; config.google.enabled = false;
config.google.accounts = [];
return; return;
} }
@@ -785,16 +786,19 @@ async function stepGoogle(config: OnboardConfig): Promise<void> {
spinner.stop('Failed to install gog'); spinner.stop('Failed to install gog');
p.log.error('Installation failed. Try manually: brew install steipete/tap/gogcli'); p.log.error('Installation failed. Try manually: brew install steipete/tap/gogcli');
config.google.enabled = false; config.google.enabled = false;
config.google.accounts = [];
return; return;
} }
} else { } else {
p.log.info('Install gog manually: brew install steipete/tap/gogcli'); p.log.info('Install gog manually: brew install steipete/tap/gogcli');
config.google.enabled = false; config.google.enabled = false;
config.google.accounts = [];
return; return;
} }
} else { } else {
p.log.info('Install gog manually from: https://gogcli.sh'); p.log.info('Install gog manually from: https://gogcli.sh');
config.google.enabled = false; config.google.enabled = false;
config.google.accounts = [];
return; return;
} }
} }
@@ -836,6 +840,7 @@ async function stepGoogle(config: OnboardConfig): Promise<void> {
if (!hasCredentials) { if (!hasCredentials) {
p.log.info('Run `gog auth credentials /path/to/client_secret.json` after downloading credentials.'); p.log.info('Run `gog auth credentials /path/to/client_secret.json` after downloading credentials.');
config.google.enabled = false; config.google.enabled = false;
config.google.accounts = [];
return; return;
} }
} }
@@ -860,60 +865,116 @@ async function stepGoogle(config: OnboardConfig): Promise<void> {
} }
} }
} }
const configuredAccounts = new Map<string, string[]>();
for (const entry of config.google.accounts) {
configuredAccounts.set(entry.account, entry.services || []);
}
let selectedAccount: string | undefined; const newAccounts: Array<{ account: string; services: string[] }> = [];
let selectedAccounts: string[] = [];
if (accounts.length > 0) {
const accountChoice = await p.select({ if (accounts.length === 0) {
message: 'Google account', const firstAccount = await addGoogleAccount();
if (!firstAccount) {
config.google.enabled = false;
config.google.accounts = [];
return;
}
newAccounts.push(firstAccount);
while (true) {
const more = await p.confirm({
message: 'Add another Google account?',
initialValue: false,
});
if (p.isCancel(more)) { p.cancel('Setup cancelled'); process.exit(0); }
if (!more) break;
const added = await addGoogleAccount();
if (added) {
newAccounts.push(added);
} else {
break;
}
}
} else {
const accountChoices = await p.multiselect({
message: 'Google accounts to enable',
options: [ options: [
...accounts.map(a => ({ value: a, label: a, hint: 'Existing account' })), ...accounts.map(a => ({ value: a, label: a, hint: 'Existing account' })),
{ value: '__new__', label: 'Add new account', hint: 'Authorize another account' }, { value: '__new__', label: 'Add new account', hint: 'Authorize another account' },
], ],
initialValue: config.google.account || accounts[0], initialValues: config.google.accounts.map(a => a.account).filter(a => accounts.includes(a)),
required: true,
}); });
if (p.isCancel(accountChoice)) { p.cancel('Setup cancelled'); process.exit(0); } if (p.isCancel(accountChoices)) { p.cancel('Setup cancelled'); process.exit(0); }
if (accountChoice === '__new__') { selectedAccounts = (accountChoices as string[]).filter(a => a !== '__new__');
selectedAccount = await addGoogleAccount(); if ((accountChoices as string[]).includes('__new__')) {
} else { while (true) {
selectedAccount = accountChoice as string; const added = await addGoogleAccount();
if (added) {
newAccounts.push(added);
} else {
break;
}
const more = await p.confirm({
message: 'Add another Google account?',
initialValue: false,
});
if (p.isCancel(more)) { p.cancel('Setup cancelled'); process.exit(0); }
if (!more) break;
}
} }
} else {
selectedAccount = await addGoogleAccount();
} }
if (!selectedAccount) { const allAccounts = Array.from(new Set([
...selectedAccounts,
...newAccounts.map(a => a.account),
]));
if (allAccounts.length === 0) {
config.google.enabled = false; config.google.enabled = false;
config.google.accounts = [];
return; return;
} }
// Select services const newAccountsByEmail = new Map<string, string[]>();
const selectedServices = await p.multiselect({ for (const entry of newAccounts) {
message: 'Which Google services do you want to enable?', newAccountsByEmail.set(entry.account, entry.services);
options: GOG_SERVICES.map(s => ({ }
value: s,
label: s.charAt(0).toUpperCase() + s.slice(1), const finalizedAccounts: Array<{ account: string; services: string[] }> = [];
hint: s === 'gmail' ? 'Read/send emails' : for (const account of allAccounts) {
s === 'calendar' ? 'View/create events' : const presetServices = newAccountsByEmail.get(account) || configuredAccounts.get(account) || ['gmail', 'calendar'];
s === 'drive' ? 'Access files' : const selectedServices = newAccountsByEmail.has(account) ? presetServices : await p.multiselect({
s === 'contacts' ? 'Look up contacts' : message: `Services to enable for ${account}`,
s === 'docs' ? 'Read documents' : options: GOG_SERVICES.map(s => ({
'Read/edit spreadsheets', value: s,
})), label: s.charAt(0).toUpperCase() + s.slice(1),
initialValues: config.google.services || ['gmail', 'calendar'], hint: s === 'gmail' ? 'Read/send emails' :
required: true, s === 'calendar' ? 'View/create events' :
}); s === 'drive' ? 'Access files' :
if (p.isCancel(selectedServices)) { p.cancel('Setup cancelled'); process.exit(0); } s === 'contacts' ? 'Look up contacts' :
s === 'docs' ? 'Read documents' :
'Read/edit spreadsheets',
})),
initialValues: presetServices,
required: true,
});
if (p.isCancel(selectedServices)) { p.cancel('Setup cancelled'); process.exit(0); }
finalizedAccounts.push({
account,
services: selectedServices as string[],
});
}
config.google.enabled = true; config.google.enabled = true;
config.google.account = selectedAccount; config.google.accounts = finalizedAccounts;
config.google.services = selectedServices as string[];
p.log.success(`Google Workspace configured: ${selectedAccount}`); p.log.success(`Google Workspace configured: ${finalizedAccounts.length} account(s)`);
} }
async function addGoogleAccount(): Promise<string | undefined> { async function addGoogleAccount(): Promise<{ account: string; services: string[] } | undefined> {
const email = await p.text({ const email = await p.text({
message: 'Google account email', message: 'Google account email',
placeholder: 'you@gmail.com', placeholder: 'you@gmail.com',
@@ -951,7 +1012,7 @@ async function addGoogleAccount(): Promise<string | undefined> {
if (result.status === 0) { if (result.status === 0) {
spinner.stop('Account authorized'); spinner.stop('Account authorized');
return email; return { account: email, services: services as string[] };
} else { } else {
spinner.stop('Authorization failed'); spinner.stop('Authorization failed');
p.log.error('Failed to authorize account. Try manually: gog auth add ' + email); p.log.error('Failed to authorize account. Try manually: gog auth add ' + email);
@@ -1009,7 +1070,10 @@ function showSummary(config: OnboardConfig): void {
// Google // Google
if (config.google.enabled) { if (config.google.enabled) {
lines.push(`Google: ${config.google.account} (${config.google.services?.join(', ') || 'all'})`); const googleAccounts = config.google.accounts.map(a => (
`${a.account} (${a.services?.join(', ') || 'all'})`
));
lines.push(`Google: ${googleAccounts.join(', ')}`);
} }
p.note(lines.join('\n'), 'Configuration'); p.note(lines.join('\n'), 'Configuration');
@@ -1257,11 +1321,21 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
selfChat: existingConfig.channels.signal?.selfChat ?? true, // Default true selfChat: existingConfig.channels.signal?.selfChat ?? true, // Default true
dmPolicy: existingConfig.channels.signal?.dmPolicy, dmPolicy: existingConfig.channels.signal?.dmPolicy,
}, },
google: { google: (() => {
enabled: existingConfig.integrations?.google?.enabled || false, const existingAccounts = existingConfig.integrations?.google?.accounts
account: existingConfig.integrations?.google?.account, ? existingConfig.integrations.google.accounts.map(a => ({
services: existingConfig.integrations?.google?.services, account: a.account,
}, services: a.services || [],
}))
: (existingConfig.integrations?.google?.account ? [{
account: existingConfig.integrations.google.account,
services: existingConfig.integrations.google.services || [],
}] : []);
return {
enabled: (existingConfig.integrations?.google?.enabled || false) || existingAccounts.length > 0,
accounts: existingAccounts,
};
})(),
heartbeat: { heartbeat: {
enabled: existingConfig.features?.heartbeat?.enabled || false, enabled: existingConfig.features?.heartbeat?.enabled || false,
interval: existingConfig.features?.heartbeat?.intervalMin?.toString(), interval: existingConfig.features?.heartbeat?.intervalMin?.toString(),
@@ -1420,7 +1494,9 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
config.signal.enabled ? ` ✓ Signal (${formatAccess(config.signal.dmPolicy, config.signal.allowedUsers)})` : ' ✗ Signal', config.signal.enabled ? ` ✓ Signal (${formatAccess(config.signal.dmPolicy, config.signal.allowedUsers)})` : ' ✗ Signal',
'', '',
'Integrations:', 'Integrations:',
config.google.enabled ? ` ✓ Google (${config.google.account} - ${config.google.services?.join(', ') || 'all'})` : ' ✗ Google Workspace', config.google.enabled && config.google.accounts.length > 0
? ` ✓ Google (${config.google.accounts.map(a => `${a.account} - ${a.services?.join(', ') || 'all'}`).join(', ')})`
: ' ✗ Google Workspace',
'', '',
'Features:', 'Features:',
config.heartbeat.enabled ? ` ✓ Heartbeat (${config.heartbeat.interval}min)` : ' ✗ Heartbeat', config.heartbeat.enabled ? ` ✓ Heartbeat (${config.heartbeat.interval}min)` : ' ✗ Heartbeat',
@@ -1503,10 +1579,17 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
integrations: { integrations: {
google: { google: {
enabled: true, enabled: true,
account: config.google.account, accounts: config.google.accounts,
services: config.google.services,
}, },
}, },
...((() => {
const gmailAccounts = config.google.accounts
.filter(a => a.services?.includes('gmail'))
.map(a => a.account);
return gmailAccounts.length > 0 ? {
polling: { gmail: { accounts: gmailAccounts } },
} : {};
})()),
} : {}), } : {}),
}; };

View File

@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { parseGmailAccounts } from './service.js';
describe('parseGmailAccounts', () => {
it('returns empty array for undefined', () => {
expect(parseGmailAccounts(undefined)).toEqual([]);
});
it('returns empty array for empty string', () => {
expect(parseGmailAccounts('')).toEqual([]);
});
it('parses single account string', () => {
expect(parseGmailAccounts('user@gmail.com')).toEqual(['user@gmail.com']);
});
it('parses comma-separated string', () => {
expect(parseGmailAccounts('a@gmail.com,b@gmail.com')).toEqual(['a@gmail.com', 'b@gmail.com']);
});
it('trims whitespace', () => {
expect(parseGmailAccounts(' a@gmail.com , b@gmail.com ')).toEqual(['a@gmail.com', 'b@gmail.com']);
});
it('deduplicates accounts', () => {
expect(parseGmailAccounts('a@gmail.com,a@gmail.com,b@gmail.com')).toEqual(['a@gmail.com', 'b@gmail.com']);
});
it('skips empty segments', () => {
expect(parseGmailAccounts('a@gmail.com,,b@gmail.com,')).toEqual(['a@gmail.com', 'b@gmail.com']);
});
it('accepts string array', () => {
expect(parseGmailAccounts(['a@gmail.com', 'b@gmail.com'])).toEqual(['a@gmail.com', 'b@gmail.com']);
});
it('deduplicates string array', () => {
expect(parseGmailAccounts(['a@gmail.com', 'a@gmail.com'])).toEqual(['a@gmail.com']);
});
it('trims string array values', () => {
expect(parseGmailAccounts([' a@gmail.com ', ' b@gmail.com '])).toEqual(['a@gmail.com', 'b@gmail.com']);
});
});

View File

@@ -10,12 +10,28 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import type { AgentSession } from '../core/interfaces.js'; 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 { export interface PollingConfig {
intervalMs: number; // Polling interval in milliseconds intervalMs: number; // Polling interval in milliseconds
workingDir: string; // For persisting state workingDir: string; // For persisting state
gmail?: { gmail?: {
enabled: boolean; enabled: boolean;
account: string; accounts: string[];
}; };
} }
@@ -24,8 +40,8 @@ export class PollingService {
private bot: AgentSession; private bot: AgentSession;
private config: PollingConfig; private config: PollingConfig;
// Track seen email IDs to detect new emails (persisted to disk) // Track seen email IDs per account to detect new emails (persisted to disk)
private seenEmailIds: Set<string> = new Set(); private seenEmailIdsByAccount: Map<string, Set<string>> = new Map();
private seenEmailsPath: string; private seenEmailsPath: string;
constructor(bot: AgentSession, config: PollingConfig) { constructor(bot: AgentSession, config: PollingConfig) {
@@ -42,8 +58,28 @@ export class PollingService {
try { try {
if (existsSync(this.seenEmailsPath)) { if (existsSync(this.seenEmailsPath)) {
const data = JSON.parse(readFileSync(this.seenEmailsPath, 'utf-8')); const data = JSON.parse(readFileSync(this.seenEmailsPath, 'utf-8'));
this.seenEmailIds = new Set(data.ids || []);
console.log(`[Polling] Loaded ${this.seenEmailIds.size} seen email IDs`); // 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) { } catch (e) {
console.error('[Polling] Failed to load seen emails:', e); console.error('[Polling] Failed to load seen emails:', e);
@@ -55,9 +91,17 @@ export class PollingService {
*/ */
private saveSeenEmails(): void { private saveSeenEmails(): void {
try { 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({ writeFileSync(this.seenEmailsPath, JSON.stringify({
ids: Array.from(this.seenEmailIds), accounts,
updatedAt: new Date().toISOString(), updatedAt: now,
}, null, 2)); }, null, 2));
} catch (e) { } catch (e) {
console.error('[Polling] Failed to save seen emails:', e); console.error('[Polling] Failed to save seen emails:', e);
@@ -74,7 +118,13 @@ export class PollingService {
} }
const enabledPollers: string[] = []; const enabledPollers: string[] = [];
if (this.config.gmail?.enabled) enabledPollers.push('Gmail'); 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) { if (enabledPollers.length === 0) {
console.log('[Polling] No pollers enabled'); console.log('[Polling] No pollers enabled');
@@ -106,16 +156,21 @@ export class PollingService {
*/ */
private async poll(): Promise<void> { private async poll(): Promise<void> {
if (this.config.gmail?.enabled) { if (this.config.gmail?.enabled) {
await this.pollGmail(); for (const account of this.config.gmail.accounts) {
await this.pollGmail(account);
}
} }
} }
/** /**
* Poll Gmail for new unread messages * Poll Gmail for new unread messages
*/ */
private async pollGmail(): Promise<void> { private async pollGmail(account: string): Promise<void> {
const account = this.config.gmail?.account;
if (!account) return; if (!account) return;
if (!this.seenEmailIdsByAccount.has(account)) {
this.seenEmailIdsByAccount.set(account, new Set());
}
const seenEmailIds = this.seenEmailIdsByAccount.get(account)!;
try { try {
// Check for unread emails (use longer window to catch any we might have missed) // Check for unread emails (use longer window to catch any we might have missed)
@@ -130,7 +185,7 @@ export class PollingService {
}); });
if (result.status !== 0) { if (result.status !== 0) {
console.log(`[Polling] 📧 Gmail check failed: ${result.stderr || 'unknown error'}`); console.log(`[Polling] Gmail check failed for ${account}: ${result.stderr || 'unknown error'}`);
return; return;
} }
@@ -147,7 +202,7 @@ export class PollingService {
const id = line.split(/\s+/)[0]; // First column is ID const id = line.split(/\s+/)[0]; // First column is ID
if (id) { if (id) {
currentEmailIds.add(id); currentEmailIds.add(id);
if (!this.seenEmailIds.has(id)) { if (!seenEmailIds.has(id)) {
newEmails.push(line); newEmails.push(line);
} }
} }
@@ -155,17 +210,17 @@ export class PollingService {
// Add new IDs to seen set (don't replace - we want to remember all seen emails) // Add new IDs to seen set (don't replace - we want to remember all seen emails)
for (const id of currentEmailIds) { for (const id of currentEmailIds) {
this.seenEmailIds.add(id); seenEmailIds.add(id);
} }
this.saveSeenEmails(); this.saveSeenEmails();
// Only notify if there are NEW emails we haven't seen before // Only notify if there are NEW emails we haven't seen before
if (newEmails.length === 0) { if (newEmails.length === 0) {
console.log(`[Polling] 📧 No new emails (${currentEmailIds.size} unread total)`); console.log(`[Polling] No new emails for ${account} (${currentEmailIds.size} unread total)`);
return; return;
} }
console.log(`[Polling] 📧 Found ${newEmails.length} NEW email(s)!`); console.log(`[Polling] Found ${newEmails.length} NEW email(s) for ${account}!`);
// Build output with header + new emails only // Build output with header + new emails only
const header = lines[0]; const header = lines[0];
@@ -179,7 +234,7 @@ export class PollingService {
'║ To send a message, use: lettabot-message send --text "..." ║', '║ To send a message, use: lettabot-message send --text "..." ║',
'╚════════════════════════════════════════════════════════════════╝', '╚════════════════════════════════════════════════════════════════╝',
'', '',
`[email] ${newEmails.length} new unread email(s):`, `[email] ${newEmails.length} new unread email(s) for ${account}:`,
'', '',
newEmailsOutput, newEmailsOutput,
'', '',
@@ -189,11 +244,11 @@ export class PollingService {
const response = await this.bot.sendToAgent(message); const response = await this.bot.sendToAgent(message);
// Log response but do NOT auto-deliver (silent mode) // Log response but do NOT auto-deliver (silent mode)
console.log(`[Polling] 📧 Agent finished (SILENT MODE)`); console.log(`[Polling] Agent finished (SILENT MODE)`);
console.log(` - Response: ${response?.slice(0, 100)}${(response?.length || 0) > 100 ? '...' : ''}`); console.log(` - Response: ${response?.slice(0, 100)}${(response?.length || 0) > 100 ? '...' : ''}`);
console.log(` - (Response NOT auto-delivered - agent uses lettabot-message CLI)`) console.log(` - (Response NOT auto-delivered - agent uses lettabot-message CLI)`)
} catch (e) { } catch (e) {
console.error('[Polling] 📧 Gmail error:', e); console.error(`[Polling] Gmail error for ${account}:`, e);
} }
} }
} }