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:
@@ -387,15 +387,18 @@ polling:
|
||||
intervalMs: 60000 # Check every 60 seconds (default: 60000)
|
||||
gmail:
|
||||
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 |
|
||||
|--------|------|---------|-------------|
|
||||
| `polling.enabled` | boolean | auto | Master switch. Defaults to `true` if any sub-config is enabled |
|
||||
| `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.accounts` | string[] | - | Gmail accounts to poll for unread messages |
|
||||
|
||||
### Legacy config path
|
||||
|
||||
@@ -405,7 +408,9 @@ For backward compatibility, Gmail polling can also be configured under `integrat
|
||||
integrations:
|
||||
google:
|
||||
enabled: true
|
||||
account: user@example.com
|
||||
accounts:
|
||||
- account: user@example.com
|
||||
services: [gmail, calendar]
|
||||
pollIntervalSec: 60
|
||||
```
|
||||
|
||||
@@ -415,7 +420,7 @@ The top-level `polling` section takes priority if both are present.
|
||||
|
||||
| Env Variable | Polling Config Equivalent |
|
||||
|--------------|--------------------------|
|
||||
| `GMAIL_ACCOUNT` | `polling.gmail.account` |
|
||||
| `GMAIL_ACCOUNT` | `polling.gmail.account` (comma-separated list allowed) |
|
||||
| `POLLING_INTERVAL_MS` | `polling.intervalMs` |
|
||||
| `PORT` | `api.port` |
|
||||
| `API_HOST` | `api.host` |
|
||||
@@ -547,7 +552,7 @@ Environment variables override config file values:
|
||||
| `WHATSAPP_SELF_CHAT_MODE` | `channels.whatsapp.selfChat` |
|
||||
| `SIGNAL_PHONE_NUMBER` | `channels.signal.phone` |
|
||||
| `OPENAI_API_KEY` | `transcription.apiKey` |
|
||||
| `GMAIL_ACCOUNT` | `polling.gmail.account` |
|
||||
| `GMAIL_ACCOUNT` | `polling.gmail.account` (comma-separated list allowed) |
|
||||
| `POLLING_INTERVAL_MS` | `polling.intervalMs` |
|
||||
|
||||
See [SKILL.md](../SKILL.md) for complete environment variable reference.
|
||||
|
||||
@@ -210,16 +210,26 @@ export function configToEnv(config: LettaBotConfig): Record<string, string> {
|
||||
}
|
||||
|
||||
// Polling - top-level polling config (preferred)
|
||||
if (config.polling?.gmail?.enabled && config.polling.gmail.account) {
|
||||
env.GMAIL_ACCOUNT = config.polling.gmail.account;
|
||||
if (config.polling?.gmail?.enabled) {
|
||||
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) {
|
||||
env.POLLING_INTERVAL_MS = String(config.polling.intervalMs);
|
||||
}
|
||||
|
||||
// Integrations - Google (legacy path for Gmail polling, lower priority)
|
||||
if (!env.GMAIL_ACCOUNT && config.integrations?.google?.enabled && config.integrations.google.account) {
|
||||
env.GMAIL_ACCOUNT = config.integrations.google.account;
|
||||
if (!env.GMAIL_ACCOUNT && config.integrations?.google?.enabled) {
|
||||
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) {
|
||||
env.POLLING_INTERVAL_MS = String(config.integrations.google.pollIntervalSec * 1000);
|
||||
|
||||
@@ -131,6 +131,7 @@ export interface PollingYamlConfig {
|
||||
gmail?: {
|
||||
enabled?: boolean; // Enable Gmail polling
|
||||
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
|
||||
}
|
||||
|
||||
export interface GoogleAccountConfig {
|
||||
account: string;
|
||||
services?: string[]; // e.g., ['gmail', 'calendar', 'drive', 'contacts', 'docs', 'sheets']
|
||||
}
|
||||
|
||||
export interface GoogleConfig {
|
||||
enabled: boolean;
|
||||
account?: string;
|
||||
accounts?: GoogleAccountConfig[];
|
||||
services?: string[]; // e.g., ['gmail', 'calendar', 'drive', 'contacts', 'docs', 'sheets']
|
||||
pollIntervalSec?: number; // Polling interval in seconds (default: 60)
|
||||
}
|
||||
|
||||
50
src/main.ts
50
src/main.ts
@@ -150,7 +150,7 @@ import { DiscordAdapter } from './channels/discord.js';
|
||||
import { GroupBatcher } from './core/group-batcher.js';
|
||||
import { CronService } from './cron/service.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';
|
||||
// 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();
|
||||
|
||||
// Per-agent polling
|
||||
const pollConfig = agentConfig.polling || (agentConfig.integrations?.google ? {
|
||||
enabled: agentConfig.integrations.google.enabled,
|
||||
intervalMs: (agentConfig.integrations.google.pollIntervalSec || 60) * 1000,
|
||||
gmail: {
|
||||
enabled: agentConfig.integrations.google.enabled,
|
||||
account: agentConfig.integrations.google.account || '',
|
||||
},
|
||||
} : undefined);
|
||||
// Per-agent polling -- resolve accounts from polling > integrations.google (legacy) > env
|
||||
const pollConfig = (() => {
|
||||
const pollingAccounts = parseGmailAccounts(
|
||||
agentConfig.polling?.gmail?.accounts || agentConfig.polling?.gmail?.account
|
||||
);
|
||||
const legacyAccounts = (() => {
|
||||
const legacy = agentConfig.integrations?.google;
|
||||
if (legacy?.accounts?.length) {
|
||||
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, {
|
||||
intervalMs: pollConfig.intervalMs || 60000,
|
||||
intervalMs: pollConfig.intervalMs,
|
||||
workingDir: globalConfig.workingDir,
|
||||
gmail: {
|
||||
enabled: pollConfig.gmail.enabled,
|
||||
account: pollConfig.gmail.account || '',
|
||||
},
|
||||
gmail: pollConfig.gmail,
|
||||
});
|
||||
pollingService.start();
|
||||
services.pollingServices.push(pollingService);
|
||||
|
||||
183
src/onboard.ts
183
src/onboard.ts
@@ -154,7 +154,7 @@ interface OnboardConfig {
|
||||
discord: { enabled: boolean; token?: string; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[] };
|
||||
|
||||
// Google Workspace (via gog CLI)
|
||||
google: { enabled: boolean; account?: string; services?: string[] };
|
||||
google: { enabled: boolean; accounts: Array<{ account: string; services: string[] }> };
|
||||
|
||||
// Features
|
||||
heartbeat: { enabled: boolean; interval?: string };
|
||||
@@ -751,6 +751,7 @@ async function stepGoogle(config: OnboardConfig): Promise<void> {
|
||||
|
||||
if (!setupGoogle) {
|
||||
config.google.enabled = false;
|
||||
config.google.accounts = [];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -785,16 +786,19 @@ async function stepGoogle(config: OnboardConfig): Promise<void> {
|
||||
spinner.stop('Failed to install gog');
|
||||
p.log.error('Installation failed. Try manually: brew install steipete/tap/gogcli');
|
||||
config.google.enabled = false;
|
||||
config.google.accounts = [];
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
p.log.info('Install gog manually: brew install steipete/tap/gogcli');
|
||||
config.google.enabled = false;
|
||||
config.google.accounts = [];
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
p.log.info('Install gog manually from: https://gogcli.sh');
|
||||
config.google.enabled = false;
|
||||
config.google.accounts = [];
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -836,6 +840,7 @@ async function stepGoogle(config: OnboardConfig): Promise<void> {
|
||||
if (!hasCredentials) {
|
||||
p.log.info('Run `gog auth credentials /path/to/client_secret.json` after downloading credentials.');
|
||||
config.google.enabled = false;
|
||||
config.google.accounts = [];
|
||||
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;
|
||||
|
||||
if (accounts.length > 0) {
|
||||
const accountChoice = await p.select({
|
||||
message: 'Google account',
|
||||
const newAccounts: Array<{ account: string; services: string[] }> = [];
|
||||
let selectedAccounts: string[] = [];
|
||||
|
||||
if (accounts.length === 0) {
|
||||
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: [
|
||||
...accounts.map(a => ({ value: a, label: a, hint: 'Existing 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 (accountChoice === '__new__') {
|
||||
selectedAccount = await addGoogleAccount();
|
||||
} else {
|
||||
selectedAccount = accountChoice as string;
|
||||
if (p.isCancel(accountChoices)) { p.cancel('Setup cancelled'); process.exit(0); }
|
||||
|
||||
selectedAccounts = (accountChoices as string[]).filter(a => a !== '__new__');
|
||||
if ((accountChoices as string[]).includes('__new__')) {
|
||||
while (true) {
|
||||
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.accounts = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Select services
|
||||
const selectedServices = await p.multiselect({
|
||||
message: 'Which Google services do you want to enable?',
|
||||
options: GOG_SERVICES.map(s => ({
|
||||
value: s,
|
||||
label: s.charAt(0).toUpperCase() + s.slice(1),
|
||||
hint: s === 'gmail' ? 'Read/send emails' :
|
||||
s === 'calendar' ? 'View/create events' :
|
||||
s === 'drive' ? 'Access files' :
|
||||
s === 'contacts' ? 'Look up contacts' :
|
||||
s === 'docs' ? 'Read documents' :
|
||||
'Read/edit spreadsheets',
|
||||
})),
|
||||
initialValues: config.google.services || ['gmail', 'calendar'],
|
||||
required: true,
|
||||
});
|
||||
if (p.isCancel(selectedServices)) { p.cancel('Setup cancelled'); process.exit(0); }
|
||||
|
||||
|
||||
const newAccountsByEmail = new Map<string, string[]>();
|
||||
for (const entry of newAccounts) {
|
||||
newAccountsByEmail.set(entry.account, entry.services);
|
||||
}
|
||||
|
||||
const finalizedAccounts: Array<{ account: string; services: string[] }> = [];
|
||||
for (const account of allAccounts) {
|
||||
const presetServices = newAccountsByEmail.get(account) || configuredAccounts.get(account) || ['gmail', 'calendar'];
|
||||
const selectedServices = newAccountsByEmail.has(account) ? presetServices : await p.multiselect({
|
||||
message: `Services to enable for ${account}`,
|
||||
options: GOG_SERVICES.map(s => ({
|
||||
value: s,
|
||||
label: s.charAt(0).toUpperCase() + s.slice(1),
|
||||
hint: s === 'gmail' ? 'Read/send emails' :
|
||||
s === 'calendar' ? 'View/create events' :
|
||||
s === 'drive' ? 'Access files' :
|
||||
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.account = selectedAccount;
|
||||
config.google.services = selectedServices as string[];
|
||||
config.google.accounts = finalizedAccounts;
|
||||
|
||||
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({
|
||||
message: 'Google account email',
|
||||
placeholder: 'you@gmail.com',
|
||||
@@ -951,7 +1012,7 @@ async function addGoogleAccount(): Promise<string | undefined> {
|
||||
|
||||
if (result.status === 0) {
|
||||
spinner.stop('Account authorized');
|
||||
return email;
|
||||
return { account: email, services: services as string[] };
|
||||
} else {
|
||||
spinner.stop('Authorization failed');
|
||||
p.log.error('Failed to authorize account. Try manually: gog auth add ' + email);
|
||||
@@ -1009,7 +1070,10 @@ function showSummary(config: OnboardConfig): void {
|
||||
|
||||
// Google
|
||||
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');
|
||||
@@ -1257,11 +1321,21 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
|
||||
selfChat: existingConfig.channels.signal?.selfChat ?? true, // Default true
|
||||
dmPolicy: existingConfig.channels.signal?.dmPolicy,
|
||||
},
|
||||
google: {
|
||||
enabled: existingConfig.integrations?.google?.enabled || false,
|
||||
account: existingConfig.integrations?.google?.account,
|
||||
services: existingConfig.integrations?.google?.services,
|
||||
},
|
||||
google: (() => {
|
||||
const existingAccounts = existingConfig.integrations?.google?.accounts
|
||||
? existingConfig.integrations.google.accounts.map(a => ({
|
||||
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: {
|
||||
enabled: existingConfig.features?.heartbeat?.enabled || false,
|
||||
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',
|
||||
'',
|
||||
'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:',
|
||||
config.heartbeat.enabled ? ` ✓ Heartbeat (${config.heartbeat.interval}min)` : ' ✗ Heartbeat',
|
||||
@@ -1503,10 +1579,17 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise<v
|
||||
integrations: {
|
||||
google: {
|
||||
enabled: true,
|
||||
account: config.google.account,
|
||||
services: config.google.services,
|
||||
accounts: config.google.accounts,
|
||||
},
|
||||
},
|
||||
...((() => {
|
||||
const gmailAccounts = config.google.accounts
|
||||
.filter(a => a.services?.includes('gmail'))
|
||||
.map(a => a.account);
|
||||
return gmailAccounts.length > 0 ? {
|
||||
polling: { gmail: { accounts: gmailAccounts } },
|
||||
} : {};
|
||||
})()),
|
||||
} : {}),
|
||||
};
|
||||
|
||||
|
||||
44
src/polling/service.test.ts
Normal file
44
src/polling/service.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
@@ -10,12 +10,28 @@ 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;
|
||||
account: string;
|
||||
accounts: string[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,8 +40,8 @@ export class PollingService {
|
||||
private bot: AgentSession;
|
||||
private config: PollingConfig;
|
||||
|
||||
// Track seen email IDs to detect new emails (persisted to disk)
|
||||
private seenEmailIds: Set<string> = new Set();
|
||||
// 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) {
|
||||
@@ -42,8 +58,28 @@ export class PollingService {
|
||||
try {
|
||||
if (existsSync(this.seenEmailsPath)) {
|
||||
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) {
|
||||
console.error('[Polling] Failed to load seen emails:', e);
|
||||
@@ -55,9 +91,17 @@ export class PollingService {
|
||||
*/
|
||||
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({
|
||||
ids: Array.from(this.seenEmailIds),
|
||||
updatedAt: new Date().toISOString(),
|
||||
accounts,
|
||||
updatedAt: now,
|
||||
}, null, 2));
|
||||
} catch (e) {
|
||||
console.error('[Polling] Failed to save seen emails:', e);
|
||||
@@ -74,7 +118,13 @@ export class PollingService {
|
||||
}
|
||||
|
||||
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) {
|
||||
console.log('[Polling] No pollers enabled');
|
||||
@@ -106,16 +156,21 @@ export class PollingService {
|
||||
*/
|
||||
private async poll(): Promise<void> {
|
||||
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
|
||||
*/
|
||||
private async pollGmail(): Promise<void> {
|
||||
const account = this.config.gmail?.account;
|
||||
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)
|
||||
@@ -130,7 +185,7 @@ export class PollingService {
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -147,7 +202,7 @@ export class PollingService {
|
||||
const id = line.split(/\s+/)[0]; // First column is ID
|
||||
if (id) {
|
||||
currentEmailIds.add(id);
|
||||
if (!this.seenEmailIds.has(id)) {
|
||||
if (!seenEmailIds.has(id)) {
|
||||
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)
|
||||
for (const id of currentEmailIds) {
|
||||
this.seenEmailIds.add(id);
|
||||
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 (${currentEmailIds.size} unread total)`);
|
||||
console.log(`[Polling] No new emails for ${account} (${currentEmailIds.size} unread total)`);
|
||||
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
|
||||
const header = lines[0];
|
||||
@@ -179,7 +234,7 @@ export class PollingService {
|
||||
'║ 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,
|
||||
'',
|
||||
@@ -189,11 +244,11 @@ export class PollingService {
|
||||
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(`[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:', e);
|
||||
console.error(`[Polling] Gmail error for ${account}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user