agentic-os/gmail-agent/index.js

366 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

require('dotenv').config();
const fs = require('fs');
const path = require('path');
const cron = require('node-cron');
const { google } = require('googleapis');
const TOKENS_DIR = path.join(__dirname, 'tokens');
const CREDENTIALS_PATH = path.join(__dirname, 'credentials.json');
function getCredentials() {
if (!fs.existsSync(CREDENTIALS_PATH) || fs.statSync(CREDENTIALS_PATH).isDirectory()) {
throw new Error('Google OAuth credentials.json is missing. Please create the gmail-agent-auth Kubernetes Secret with your Google Cloud OAuth credentials.');
}
return JSON.parse(fs.readFileSync(CREDENTIALS_PATH));
}
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || 'AIzaSyDKYcgVPN2oJirwzf_td3sBYMnXHWfVphU';
const WHATSAPP_GATEWAY_URL = process.env.WHATSAPP_GATEWAY_URL || 'http://whatsapp-gateway.ai-agents.svc.cluster.local:5001/api/send-message';
const WHATSAPP_TO = process.env.WHATSAPP_TO;
let baseOAuth2Client;
let accounts = [];
function loadAuth() {
try {
if (!fs.existsSync(CREDENTIALS_PATH) || fs.statSync(CREDENTIALS_PATH).isDirectory()) {
console.warn('⚠️ Missing credentials.json. Gmail Agent will not be able to process emails or authenticate new accounts until this file is provided.');
return;
}
if (!fs.existsSync(TOKENS_DIR)) {
fs.mkdirSync(TOKENS_DIR, { recursive: true });
console.log('Created empty tokens directory.');
}
const creds = getCredentials();
const {client_secret, client_id, redirect_uris} = creds.installed || creds.web;
baseOAuth2Client = { client_id, client_secret, redirect_uri: redirect_uris[0] || 'urn:ietf:wg:oauth:2.0:oob' };
const tokenFiles = fs.readdirSync(TOKENS_DIR).filter(file => file.endsWith('.json'));
if (tokenFiles.length === 0) {
console.log(' No token files found in tokens/. Waiting for accounts to be added via UI.');
return;
}
for (const file of tokenFiles) {
const accountName = file.replace('.json', '');
const tokenContent = JSON.parse(fs.readFileSync(path.join(TOKENS_DIR, file)));
const client = new google.auth.OAuth2(baseOAuth2Client.client_id, baseOAuth2Client.client_secret, baseOAuth2Client.redirect_uri);
client.setCredentials(tokenContent);
accounts.push({ name: accountName, client });
console.log(`✅ Loaded credentials for account: ${accountName}`);
}
} catch (err) {
console.error('❌ Error loading auth files:', err.message);
}
}
async function ensureAgentReadLabel(account) {
const gmail = google.gmail({ version: 'v1', auth: account.client });
const res = await gmail.users.labels.list({ userId: 'me' });
const labels = res.data.labels || [];
let agentReadLabel = labels.find(l => l.name === 'AGENT_READ');
if (!agentReadLabel) {
console.log(`Creating AGENT_READ label for account ${account.name}...`);
const createRes = await gmail.users.labels.create({
userId: 'me',
requestBody: {
name: 'AGENT_READ',
labelListVisibility: 'labelShow',
messageListVisibility: 'show'
}
});
agentReadLabel = createRes.data;
}
return agentReadLabel.id;
}
async function getUnreadEmails(account) {
const gmail = google.gmail({ version: 'v1', auth: account.client });
const res = await gmail.users.messages.list({
userId: 'me',
q: '-label:AGENT_READ newer_than:2d',
maxResults: 10
});
const messages = res.data.messages || [];
const emails = [];
for (const msg of messages) {
const fullMsg = await gmail.users.messages.get({ userId: 'me', id: msg.id, format: 'full' });
const headers = fullMsg.data.payload.headers;
const subject = headers.find(h => h.name === 'Subject')?.value || 'No Subject';
const from = headers.find(h => h.name === 'From')?.value || 'Unknown';
let body = '';
if (fullMsg.data.payload.parts) {
const part = fullMsg.data.payload.parts.find(p => p.mimeType === 'text/plain');
if (part && part.body.data) {
body = Buffer.from(part.body.data, 'base64').toString('utf8');
}
} else if (fullMsg.data.payload.body && fullMsg.data.payload.body.data) {
body = Buffer.from(fullMsg.data.payload.body.data, 'base64').toString('utf8');
}
emails.push({ id: msg.id, subject, from, body: body.substring(0, 1000), accountName: account.name, client: account.client });
}
return emails;
}
async function markAsAgentRead(email, labelId) {
const gmail = google.gmail({ version: 'v1', auth: email.client });
await gmail.users.messages.modify({
userId: 'me',
id: email.id,
requestBody: { addLabelIds: [labelId] }
});
}
async function analyzeWithGemini(email) {
const prompt = `You are a strict AI assistant. Your ONLY job is to filter emails to send to WhatsApp.
CRITICAL RULES:
1. STRICTLY IGNORE ALL promotional emails, newsletters, daily updates, market recaps, deals, marketing, product updates, surveys, or general spam. If it looks like a mass email or newsletter, it is NOT important.
2. ONLY flag truly personal messages, urgent requests from real people, travel updates, direct security alerts, or specific receipts.
Email Subject: ${email.subject}
Email From: ${email.from}
Email Body Preview:
${email.body}
Determine if this is an important email. Return a strict JSON response. Do not include markdown formatting.
Format:
{
"isImportant": true or false,
"summary": "1-sentence summary of what the user needs to know if true, otherwise leave empty"
}`;
try {
const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
temperature: 0.1,
responseMimeType: "application/json"
}
})
});
const data = await res.json();
if (data.candidates && data.candidates.length > 0) {
const text = data.candidates[0].content.parts[0].text;
const parsed = JSON.parse(text);
return { isImportant: parsed.isImportant, summary: parsed.summary || '' };
}
return { isImportant: false, summary: '' };
} catch (err) {
console.error('Gemini Error:', err.message);
return { isImportant: false, summary: '' };
}
}
async function sendWhatsAppNotification(email, summary) {
const message = `📧 *[${email.accountName}] Important Email*\n*From:* ${email.from}\n*Subject:* ${email.subject}\n\n🤖 *Summary:* ${summary}`;
try {
await fetch(WHATSAPP_GATEWAY_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-app-name': 'Gmail Agent'
},
body: JSON.stringify({
to: 'default',
message: message
})
});
console.log(`Notification sent for: [${email.accountName}] ${email.subject}`);
} catch (err) {
console.error('WhatsApp Gateway Error:', err.message);
}
}
const ORCHESTRATOR_URL = process.env.ORCHESTRATOR_URL || 'http://agents-api.agents-runtime.svc.cluster.local:8000/webhook';
async function forwardToOrchestrator(email) {
console.log(`Forwarding email from owner to orchestrator: ${email.subject}`);
try {
await fetch(ORCHESTRATOR_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `Subject: ${email.subject}\n\n${email.body}`,
sender: email.from,
targetJid: 'EMAIL'
})
});
} catch (err) {
console.error('Failed to forward to orchestrator:', err.message);
}
}
async function processEmails() {
console.log(`[${new Date().toISOString()}] Checking for new emails across ${accounts.length} accounts...`);
try {
for (const account of accounts) {
console.log(`Checking account: ${account.name}...`);
if (!account.agentReadLabelId) {
account.agentReadLabelId = await ensureAgentReadLabel(account);
}
const emails = await getUnreadEmails(account);
if (emails.length === 0) {
console.log(`No new emails for ${account.name}.`);
continue;
}
console.log(`Found ${emails.length} unread emails for ${account.name}.`);
for (const email of emails) {
if (email.from.toLowerCase().includes('deepkoluguri@gmail.com')) {
await forwardToOrchestrator(email);
} else {
const analysis = await analyzeWithGemini(email);
if (analysis.isImportant) {
await sendWhatsAppNotification(email, analysis.summary);
} else {
console.log(`Skipped non-important email: ${email.subject}`);
}
}
// Mark as processed using our custom label
await markAsAgentRead(email, account.agentReadLabelId);
}
}
} catch (err) {
console.error('Error processing emails:', err.message);
}
}
// ─── Express Server for UI Integration ─────────────────────────────────────────
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors());
app.use(express.json());
// Send an email (called by orchestrator)
app.post('/api/send-email', async (req, res) => {
const { to, subject, body } = req.body;
if (!to || !subject || !body) return res.status(400).json({ error: 'Missing to, subject, or body' });
// Use the first connected account to send the email
if (accounts.length === 0) return res.status(500).json({ error: 'No Gmail accounts connected' });
const account = accounts[0];
try {
const gmail = google.gmail({ version: 'v1', auth: account.client });
const rawMessage = [
`To: ${to}`,
`Subject: ${subject}`,
'Content-Type: text/plain; charset="UTF-8"',
'',
body
].join('\n');
const encodedMessage = Buffer.from(rawMessage).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
await gmail.users.messages.send({
userId: 'me',
requestBody: {
raw: encodedMessage
}
});
console.log(`Sent email to ${to} with subject: ${subject}`);
res.json({ success: true });
} catch (err) {
console.error('Error sending email:', err.message);
res.status(500).json({ error: err.message });
}
});
// Get all connected accounts
app.get('/api/accounts', (req, res) => {
res.json({
success: true,
accounts: accounts.map(a => ({ name: a.name }))
});
});
// Generate Auth URL for a new account
app.post('/api/auth/url', (req, res) => {
const { accountName } = req.body;
if (!accountName) return res.status(400).json({ success: false, error: 'accountName is required' });
try {
const creds = getCredentials();
const {client_secret, client_id, redirect_uris} = creds.installed || creds.web;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0] || 'urn:ietf:wg:oauth:2.0:oob');
const SCOPES = ['https://www.googleapis.com/auth/gmail.readonly', 'https://www.googleapis.com/auth/gmail.modify'];
const authUrl = oAuth2Client.generateAuthUrl({
access_type: 'offline',
prompt: 'consent',
scope: SCOPES,
state: accountName // pass the accountName in state so we don't lose it across redirects
});
res.json({ success: true, url: authUrl });
} catch (e) {
res.status(500).json({ success: false, error: e.message });
}
});
// Verify token and hot-reload account
app.post('/api/auth/token', (req, res) => {
const { accountName, code } = req.body;
if (!accountName || !code) return res.status(400).json({ success: false, error: 'accountName and code are required' });
try {
const creds = getCredentials();
const {client_secret, client_id, redirect_uris} = creds.installed || creds.web;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0] || 'urn:ietf:wg:oauth:2.0:oob');
oAuth2Client.getToken(code, (err, token) => {
if (err) return res.status(400).json({ success: false, error: err.message });
const tokenPath = path.join(TOKENS_DIR, `${accountName}.json`);
fs.writeFileSync(tokenPath, JSON.stringify(token));
// Reload accounts list
accounts = [];
loadAuth();
res.json({ success: true, message: `Account ${accountName} added successfully!` });
});
} catch (e) {
res.status(500).json({ success: false, error: e.message });
}
});
const PORT = process.env.PORT || 5002;
app.listen(PORT, '0.0.0.0', () => {
console.log(`Express server listening on 0.0.0.0:${PORT}`);
});
// Start Application
try {
loadAuth();
console.log('Gmail Agent started successfully.');
console.log(`Cron job scheduled to check every 15 minutes.`);
// Check immediately on startup
processEmails();
// Schedule every 15 minutes
cron.schedule('*/15 * * * *', () => {
processEmails();
});
} catch (e) {
console.error('Failed to start:', e.message);
}