#!/usr/bin/env node /* Google Workspace helper CLI Commands: whoami send --to --subject --body [--html] [--attach ] search-mail --query [--max 10] search-calendar --query [--max 10] [--timeMin ISO] [--timeMax ISO] [--calendar primary] create-event --summary --start --end [--timeZone America/Chicago] [--description ] [--location ] [--calendar primary] */ const fs = require('fs'); const path = require('path'); const DEFAULT_SUBJECT = process.env.GW_IMPERSONATE || 'stefano@fiorinis.com'; const DEFAULT_KEY_CANDIDATES = [ process.env.GW_KEY_PATH, path.join(process.env.HOME || '', '.openclaw/workspace/.clawdbot/credentials/google-workspace/service-account.json'), path.join(process.env.HOME || '', '.clawdbot/credentials/google-workspace/service-account.json'), ].filter(Boolean); const SCOPES = [ 'https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/gmail.compose', 'https://www.googleapis.com/auth/gmail.readonly', 'https://www.googleapis.com/auth/calendar', 'https://www.googleapis.com/auth/calendar.events', ]; function resolveKeyPath() { for (const p of DEFAULT_KEY_CANDIDATES) { if (p && fs.existsSync(p)) return p; } return null; } function parseArgs(argv) { const out = { _: [] }; for (let i = 0; i < argv.length; i++) { const token = argv[i]; if (token.startsWith('--')) { const key = token.slice(2); const next = argv[i + 1]; if (!next || next.startsWith('--')) { out[key] = true; } else { if (Object.hasOwn(out, key)) { out[key] = Array.isArray(out[key]) ? out[key].concat(next) : [out[key], next]; } else { out[key] = next; } i++; } } else { out._.push(token); } } return out; } function usage() { console.log(`Google Workspace CLI\n Env (optional): GW_IMPERSONATE user to impersonate (default: ${DEFAULT_SUBJECT}) GW_KEY_PATH service-account key path Commands: whoami send --to --subject --body [--html] [--attach ] search-mail --query [--max 10] search-calendar --query [--max 10] [--timeMin ISO] [--timeMax ISO] [--calendar primary] create-event --summary --start --end [--timeZone America/Chicago] [--description ] [--location ] [--calendar primary] `); } function assertRequired(opts, required) { const missing = required.filter((k) => !opts[k]); if (missing.length) { throw new Error(`Missing required options: ${missing.map((m) => `--${m}`).join(', ')}`); } } function toArray(value) { if (value == null || value === false) return []; return Array.isArray(value) ? value : [value]; } function getAttachmentContentType(filename) { const ext = path.extname(filename).toLowerCase(); if (ext === '.pdf') return 'application/pdf'; if (ext === '.txt') return 'text/plain; charset="UTF-8"'; if (ext === '.html' || ext === '.htm') return 'text/html; charset="UTF-8"'; if (ext === '.json') return 'application/json'; return 'application/octet-stream'; } function wrapBase64(base64) { return base64.match(/.{1,76}/g)?.join('\r\n') || ''; } function loadAttachments(attachArg) { return toArray(attachArg).map((filePath) => { const absolutePath = path.resolve(filePath); if (!fs.existsSync(absolutePath)) { throw new Error(`Attachment file not found: ${absolutePath}`); } return { filename: path.basename(absolutePath), contentType: getAttachmentContentType(absolutePath), data: fs.readFileSync(absolutePath).toString('base64'), }; }); } function makeRawEmail({ from, to, subject, body, isHtml = false, attachments = [] }) { const contentType = isHtml ? 'text/html; charset="UTF-8"' : 'text/plain; charset="UTF-8"'; const normalizedAttachments = attachments.filter(Boolean); let msg; if (normalizedAttachments.length === 0) { msg = [ `From: ${from}`, `To: ${to}`, `Subject: ${subject}`, 'MIME-Version: 1.0', `Content-Type: ${contentType}`, '', body, ].join('\r\n'); } else { const boundary = `gw-boundary-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; msg = [ `From: ${from}`, `To: ${to}`, `Subject: ${subject}`, 'MIME-Version: 1.0', `Content-Type: multipart/mixed; boundary="${boundary}"`, '', `--${boundary}`, `Content-Type: ${contentType}`, 'Content-Transfer-Encoding: 7bit', '', body, '', ...normalizedAttachments.flatMap((attachment) => [ `--${boundary}`, `Content-Type: ${attachment.contentType}; name="${attachment.filename}"`, 'Content-Transfer-Encoding: base64', `Content-Disposition: attachment; filename="${attachment.filename}"`, '', wrapBase64(attachment.data), '', ]), `--${boundary}--`, '', ].join('\r\n'); } return Buffer.from(msg) .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/g, ''); } async function getClients() { const { google } = require('googleapis'); const keyPath = resolveKeyPath(); if (!keyPath) { throw new Error('Service account key not found. Set GW_KEY_PATH or place the file in ~/.openclaw/workspace/.clawdbot/credentials/google-workspace/service-account.json'); } const auth = new google.auth.GoogleAuth({ keyFile: keyPath, scopes: SCOPES, clientOptions: { subject: DEFAULT_SUBJECT }, }); const authClient = await auth.getClient(); const gmail = google.gmail({ version: 'v1', auth: authClient }); const calendar = google.calendar({ version: 'v3', auth: authClient }); return { gmail, calendar, keyPath }; } async function cmdWhoami(clients) { const profile = await clients.gmail.users.getProfile({ userId: 'me' }); console.log(JSON.stringify({ impersonating: DEFAULT_SUBJECT, keyPath: clients.keyPath, profile: profile.data, }, null, 2)); } async function cmdSend(clients, opts) { assertRequired(opts, ['to', 'subject', 'body']); const raw = makeRawEmail({ from: DEFAULT_SUBJECT, to: opts.to, subject: opts.subject, body: opts.body, isHtml: !!opts.html, attachments: loadAttachments(opts.attach), }); const res = await clients.gmail.users.messages.send({ userId: 'me', requestBody: { raw }, }); console.log(JSON.stringify({ ok: true, id: res.data.id, threadId: res.data.threadId }, null, 2)); } async function cmdSearchMail(clients, opts) { assertRequired(opts, ['query']); const maxResults = Math.max(1, Math.min(50, Number(opts.max || 10))); const list = await clients.gmail.users.messages.list({ userId: 'me', q: opts.query, maxResults, }); const ids = (list.data.messages || []).map((m) => m.id).filter(Boolean); const out = []; for (const id of ids) { const msg = await clients.gmail.users.messages.get({ userId: 'me', id, format: 'metadata', metadataHeaders: ['From', 'To', 'Subject', 'Date'], }); const headers = Object.fromEntries((msg.data.payload?.headers || []).map((h) => [h.name, h.value])); out.push({ id, threadId: msg.data.threadId, snippet: msg.data.snippet, from: headers.From, to: headers.To, subject: headers.Subject, date: headers.Date, }); } console.log(JSON.stringify({ count: out.length, messages: out }, null, 2)); } async function cmdSearchCalendar(clients, opts) { const maxResults = Math.max(1, Math.min(50, Number(opts.max || 10))); const res = await clients.calendar.events.list({ calendarId: opts.calendar || 'primary', q: opts.query || undefined, timeMin: opts.timeMin, timeMax: opts.timeMax, singleEvents: true, orderBy: 'startTime', maxResults, }); const events = (res.data.items || []).map((e) => ({ id: e.id, summary: e.summary, status: e.status, start: e.start, end: e.end, location: e.location, hangoutLink: e.hangoutLink, })); console.log(JSON.stringify({ count: events.length, events }, null, 2)); } async function cmdCreateEvent(clients, opts) { assertRequired(opts, ['summary', 'start', 'end']); const tz = opts.timeZone || 'America/Chicago'; const event = { summary: opts.summary, description: opts.description, location: opts.location, start: { dateTime: opts.start, timeZone: tz }, end: { dateTime: opts.end, timeZone: tz }, }; const res = await clients.calendar.events.insert({ calendarId: opts.calendar || 'primary', requestBody: event, }); console.log(JSON.stringify({ ok: true, id: res.data.id, htmlLink: res.data.htmlLink }, null, 2)); } async function main() { try { const args = parseArgs(process.argv.slice(2)); const cmd = args._[0]; if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') { usage(); process.exit(0); } const clients = await getClients(); if (cmd === 'whoami') return await cmdWhoami(clients); if (cmd === 'send') return await cmdSend(clients, args); if (cmd === 'search-mail') return await cmdSearchMail(clients, args); if (cmd === 'search-calendar') return await cmdSearchCalendar(clients, args); if (cmd === 'create-event') return await cmdCreateEvent(clients, args); throw new Error(`Unknown command: ${cmd}`); } catch (err) { console.error(`ERROR: ${err.message}`); process.exit(1); } } if (require.main === module) { main(); } module.exports = { getAttachmentContentType, loadAttachments, main, makeRawEmail, parseArgs, resolveKeyPath, toArray, wrapBase64, };